From af6859144c9416dae28ac7f0014540cb87890e21 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 08:02:18 -0300 Subject: [PATCH 01/23] refactor(objects): galaxies, nebulae and clusters with a package are placed bodies of the world context, not catalogue rows --- packages/astronomy/cli/body-records.mts | 14 ++++--- .../astronomy/data/bodies/fornax-cluster.json | 28 +++++++++++++ packages/astronomy/data/bodies/helix.json | 28 +++++++++++++ packages/astronomy/data/bodies/lmc.json | 28 +++++++++++++ packages/astronomy/data/bodies/m1.json | 28 +++++++++++++ packages/astronomy/data/bodies/m2-9.json | 28 +++++++++++++ packages/astronomy/data/bodies/m31.json | 28 +++++++++++++ packages/astronomy/data/bodies/m33.json | 28 +++++++++++++ packages/astronomy/data/bodies/m42.json | 28 +++++++++++++ packages/astronomy/data/bodies/m45.json | 28 +++++++++++++ packages/astronomy/data/bodies/m8.json | 28 +++++++++++++ packages/astronomy/data/bodies/m81.json | 28 +++++++++++++ packages/astronomy/data/bodies/m83.json | 28 +++++++++++++ packages/astronomy/data/bodies/m87.json | 28 +++++++++++++ packages/astronomy/data/bodies/ngc-253.json | 28 +++++++++++++ packages/astronomy/data/bodies/ngc-300.json | 28 +++++++++++++ .../astronomy/data/bodies/omega-centauri.json | 28 +++++++++++++ packages/astronomy/data/bodies/smc.json | 28 +++++++++++++ .../astronomy/data/bodies/virgo-cluster.json | 28 +++++++++++++ packages/astronomy/src/bodies.ts | 2 +- packages/astronomy/src/body-types.ts | 4 +- packages/bake/cli/prepare-solar-geometry.mts | 7 +++- .../bake/src/world-context/spatial-context.ts | 6 +-- packages/objects/src/registry/index.ts | 2 +- .../objects/src/registry/object-catalog.ts | 9 ++-- .../objects/src/registry/object-schema.ts | 8 +++- site/build/prepare/prepare-catalog.mts | 3 +- .../prepare/prepare-object-discovery.mts | 12 +++++- site/build/prepare/prepare-spatial-context.ts | 7 ++-- src/objects/fornax-cluster/object.json | 37 +++++++++++++++++ src/objects/helix/object.json | 35 ++++++++++++++++ src/objects/lmc/object.json | 37 +++++++++++++++++ src/objects/m1/object.json | 37 +++++++++++++++++ src/objects/m2-9/object.json | 36 ++++++++++++++++ src/objects/m31/object.json | 37 ++++++++++++++++- src/objects/m33/object.json | 39 +++++++++++++++++- src/objects/m42/object.json | 36 ++++++++++++++++ src/objects/m45/object.json | 37 +++++++++++++++++ src/objects/m8/object.json | 37 +++++++++++++++++ src/objects/m81/object.json | 41 ++++++++++++++++++- src/objects/m83/object.json | 40 +++++++++++++++++- src/objects/m87/object.json | 37 +++++++++++++++++ src/objects/ngc-253/object.json | 39 +++++++++++++++++- src/objects/ngc-300/object.json | 34 ++++++++++++++- src/objects/omega-centauri/object.json | 37 +++++++++++++++++ src/objects/smc/object.json | 38 +++++++++++++++++ src/objects/virgo-cluster/object.json | 36 ++++++++++++++++ 47 files changed, 1216 insertions(+), 32 deletions(-) create mode 100644 packages/astronomy/data/bodies/fornax-cluster.json create mode 100644 packages/astronomy/data/bodies/helix.json create mode 100644 packages/astronomy/data/bodies/lmc.json create mode 100644 packages/astronomy/data/bodies/m1.json create mode 100644 packages/astronomy/data/bodies/m2-9.json create mode 100644 packages/astronomy/data/bodies/m31.json create mode 100644 packages/astronomy/data/bodies/m33.json create mode 100644 packages/astronomy/data/bodies/m42.json create mode 100644 packages/astronomy/data/bodies/m45.json create mode 100644 packages/astronomy/data/bodies/m8.json create mode 100644 packages/astronomy/data/bodies/m81.json create mode 100644 packages/astronomy/data/bodies/m83.json create mode 100644 packages/astronomy/data/bodies/m87.json create mode 100644 packages/astronomy/data/bodies/ngc-253.json create mode 100644 packages/astronomy/data/bodies/ngc-300.json create mode 100644 packages/astronomy/data/bodies/omega-centauri.json create mode 100644 packages/astronomy/data/bodies/smc.json create mode 100644 packages/astronomy/data/bodies/virgo-cluster.json 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/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.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/cli/prepare-solar-geometry.mts b/packages/bake/cli/prepare-solar-geometry.mts index d1f3e4b1f08..709deb73c59 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() { @@ -62,7 +63,7 @@ export async function prepareSolarGeometry() { const isHostedPlanet = (id: string) => isIncluded(HOSTED_PLANET_IDS, id); const PACKAGED = readPreparedObjects(resolve(import.meta.dirname, "../../..")).sceneObjects.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,6 +123,8 @@ 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"))); + // A dataset package (a galaxy, a nebula, a cluster) has no body recipe and no rotation: its body-fixed frame is the ICRF. + if (requireRecord(descriptor.properties).recipe === undefined) return [id, { poleRightAscensionRad: 0, poleDeclinationRad: Math.PI / 2, primeMeridianRad: 0, spinRateRadPerDay: 0 }]; const recipe = requireRecord(requireRecord(descriptor.properties).recipe); const ref = requireArray(recipe.sources).map(source => requireRecord(source)).find(source => source.id === "rotation"); if (!ref) return [id, null]; @@ -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/world-context/spatial-context.ts b/packages/bake/src/world-context/spatial-context.ts index 8819416f246..f1dac47f5be 100644 --- a/packages/bake/src/world-context/spatial-context.ts +++ b/packages/bake/src/world-context/spatial-context.ts @@ -516,10 +516,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/src/registry/index.ts b/packages/objects/src/registry/index.ts index bf57a6aa412..d9c0d554af2 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -14,7 +14,7 @@ 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, 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'; diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index 6666e9f7c0b..ce1494c497a 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -1,7 +1,7 @@ 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'; @@ -16,11 +16,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 { diff --git a/packages/objects/src/registry/object-schema.ts b/packages/objects/src/registry/object-schema.ts index 06c097f41cf..5887e2820fe 100644 --- a/packages/objects/src/registry/object-schema.ts +++ b/packages/objects/src/registry/object-schema.ts @@ -17,7 +17,12 @@ 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. */ +export const PLACED_CLASSIFICATIONS: readonly ObjectClassification[] = Object.freeze(['star', 'black-hole', 'galaxy', 'galaxy-cluster', 'nebula', 'globular-cluster']); +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 { @@ -48,6 +53,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 { diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index e0e51890380..16186981109 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -142,7 +142,8 @@ export async function prepareCatalog({ projectRoot = root } = {}) { 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 spatial catalogues are data: they name no destination. Every page is a package's own entry. + const focuses: never[] = []; // 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]); diff --git a/site/build/prepare/prepare-object-discovery.mts b/site/build/prepare/prepare-object-discovery.mts index 89a9f907c18..81664539fc3 100644 --- a/site/build/prepare/prepare-object-discovery.mts +++ b/site/build/prepare/prepare-object-discovery.mts @@ -96,9 +96,19 @@ export async function preparedRuntimeCamera(path: string): Promise { } finally { await handle.close(); } } +/** A dataset package (image layers, a volume, a bank of catalogue points) has no body recipe: what it shows is its + * observations. Images and volumes built from them are imagery; a bank of catalogue points is measured, not pictured. Each is + * a place on the map by itself, so it is featured. */ +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) && descriptor.properties.recipe === undefined && typeof descriptor.type === 'string' && Object.hasOwn(DATASET_PACKAGE_IMAGERY, descriptor.type)) { + const policy = discoveryPolicy(descriptor.properties.catalog); + return { imagery: DATASET_PACKAGE_IMAGERY[descriptor.type]!, 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 f06fb4c5006..0b134b3d9c2 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 { parseObjectDescriptor } from '@cssearth/objects'; +import { isPlacedClassification, parseObjectDescriptor } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import { packPreparedBinary, readCatalog, readPreparedObjects } from '@cssearth/objects/node'; import { worldOrbitBankRegions } from '@cssearth/renderer'; @@ -160,7 +160,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. @@ -337,7 +337,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/src/objects/fornax-cluster/object.json b/src/objects/fornax-cluster/object.json index 8e5dc306fc7..ed5417723b8 100644 --- a/src/objects/fornax-cluster/object.json +++ b/src/objects/fornax-cluster/object.json @@ -5,6 +5,43 @@ "properties": { "preparation": { "source": "source/dots/points.json" + }, + "catalog": { + "name": "Fornax Cluster", + "systemName": "Galaxy clusters", + "classification": "galaxy-cluster", + "color": "#9a9a9a", + "distanceAu": 4055320034000, + "description": "A cluster of galaxies 19.7 million parsecs away.", + "aliases": [ + "RXCJ0338.4-3526", + "FORNAX", + "MCXC J0338.4-3526" + ], + "order": 5136, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 2.8617512664442398e+23, + 4.02930234971015e+23, + -3.518467946012384e+23 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 1.912022062157274e+22, + "bodyRadiusM": 1.912022062157274e+22 } }, "prepared": { diff --git a/src/objects/helix/object.json b/src/objects/helix/object.json index df61b936395..53100e4bae2 100644 --- a/src/objects/helix/object.json +++ b/src/objects/helix/object.json @@ -33,6 +33,41 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Helix Nebula", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 44553198.15, + "description": "The Helix is a planetary nebula about 216 parsecs away. Its broad ring of glowing gas surrounds the remnant central star.", + "aliases": [ + "NGC 7293" + ], + "order": 5137, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 5751238142720137000, + -2392748347554614000, + -2370847812686342000 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 32313140071200, + "bodyRadiusM": 48469710106800000 } }, "prepared": { diff --git a/src/objects/lmc/object.json b/src/objects/lmc/object.json index 94206f81d86..f2fe5918b7f 100644 --- a/src/objects/lmc/object.json +++ b/src/objects/lmc/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/compact-delivery.json" + }, + "catalog": { + "name": "LMC", + "systemName": "Local Group", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 10228810510, + "description": "A galaxy 49,591 parsecs away.", + "aliases": [ + "Nubecula Major", + "Large Magellanic Cloud", + "Large Milky Cloud" + ], + "order": 5138, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 105965323959178080000, + 533210010179927000000, + -1.4303833279717612e+21 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 154283879074568370000 } }, "prepared": { diff --git a/src/objects/m1/object.json b/src/objects/m1/object.json index 05227dbe40e..19582bb33f4 100644 --- a/src/objects/m1/object.json +++ b/src/objects/m1/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Crab Nebula", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 412529612.5, + "description": "The Crab combines an expanding supernova remnant with a pulsar wind nebula. It lies about 2,000 parsecs away.", + "aliases": [ + "M1", + "NGC 1952", + "Crab" + ], + "order": 5139, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 6344354362860136000, + 56860857745615634000, + 23133406124448793000 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 299195741400000, + "bodyRadiusM": 40627486340224216 } }, "prepared": { diff --git a/src/objects/m2-9/object.json b/src/objects/m2-9/object.json index 60bc2f52538..5f4c4aaf9f6 100644 --- a/src/objects/m2-9/object.json +++ b/src/objects/m2-9/object.json @@ -33,6 +33,42 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Twin Jet Nebula", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 134072124.1, + "description": "The Twin Jet is a bipolar nebula with two opposing lobes. Published distance estimates range from 650 to about 1,300 parsecs.", + "aliases": [ + "M2–9", + "PN M2-9" + ], + "order": 5140, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -4639854198945162000, + -19190526609290568000, + -3531975474921166000 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 564324590702700, + "bodyRadiusM": 2821622953513500 } }, "prepared": { diff --git a/src/objects/m31/object.json b/src/objects/m31/object.json index 0c58cfb2a8e..85e779cd54e 100644 --- a/src/objects/m31/object.json +++ b/src/objects/m31/object.json @@ -37,7 +37,42 @@ "cataloguePoints": [ "stars", "dots" - ] + ], + "catalog": { + "name": "M 31", + "systemName": "Local Group", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 160112461100, + "description": "A galaxy 776,247 parsecs away.", + "aliases": [ + "Andromeda" + ], + "order": 5141, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 1.7692007682599815e+22, + 3.3377962460728383e+21, + 1.5797892971967472e+22 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 771419395372841800000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m33/object.json b/src/objects/m33/object.json index ac1fc0fa021..7b6838f0881 100644 --- a/src/objects/m33/object.json +++ b/src/objects/m33/object.json @@ -37,7 +37,44 @@ "cataloguePoints": [ "stars", "dots" - ] + ], + "catalog": { + "name": "M 33", + "systemName": "Local Group", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 177184257600, + "description": "A galaxy 859,014 parsecs away.", + "aliases": [ + "Triangulum", + "NGC 598", + "UGC 1117" + ], + "order": 5142, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 2.091586510501188e+22, + 9.077987154992237e+21, + 1.3516852986079745e+22 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 277710982334223060000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m42/object.json b/src/objects/m42/object.json index 2f72926f281..21ae59f992c 100644 --- a/src/objects/m42/object.json +++ b/src/objects/m42/object.json @@ -33,6 +33,42 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Orion Nebula", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 85393629.79, + "description": "The Orion Nebula is a Milky Way H II region about 414 parsecs away. Its bright gas surrounds the young Orion Nebula Cluster.", + "aliases": [ + "M42", + "NGC 1976" + ], + "order": 5143, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 1376373810181025800, + 12643329345247054000, + -1202293075751323600 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 61933518469800, + "bodyRadiusM": 148640444327520000 } }, "prepared": { diff --git a/src/objects/m45/object.json b/src/objects/m45/object.json index c9e53cab961..0a63209a8cb 100644 --- a/src/objects/m45/object.json +++ b/src/objects/m45/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Pleiades", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 28093266.61, + "description": "The Pleiades is an open star cluster surrounded by reflection nebulosity. Its stars lie about 136 parsecs away; the dust may not.", + "aliases": [ + "M45", + "Seven Sisters", + "Pleiades reflection nebula" + ], + "order": 5144, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 2103179275207846700, + 3207881506511226400, + 1717207646325540600 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 20375229989339.996, + "bodyRadiusM": 109203529398159400 } }, "prepared": { diff --git a/src/objects/m8/object.json b/src/objects/m8/object.json index 359a665a4da..6d0803f219c 100644 --- a/src/objects/m8/object.json +++ b/src/objects/m8/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Lagoon Nebula", + "systemName": "Milky Way", + "classification": "nebula", + "color": "#9a9a9a", + "distanceAu": 273507133.1, + "description": "The Lagoon is a star-forming H II region about 1,326 parsecs away. Its young NGC 6530 cluster shares the surrounding cloud complex.", + "aliases": [ + "M8", + "NGC 6523", + "Lagoon" + ], + "order": 5145, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 498068231034880400, + -37285896590482730000, + -16841610171260310000 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 198366776548200, + "bodyRadiusM": 677379746660464100 } }, "prepared": { diff --git a/src/objects/m81/object.json b/src/objects/m81/object.json index d1154b16dd1..a960b9a5582 100644 --- a/src/objects/m81/object.json +++ b/src/objects/m81/object.json @@ -36,7 +36,46 @@ }, "cataloguePoints": [ "dots" - ] + ], + "catalog": { + "name": "M81", + "systemName": "Galaxy catalogue", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 745461352900, + "description": "A galaxy 3.6 million parsecs away.", + "aliases": [ + "NGC 3031", + "UGC 5318", + "PGC 28630", + "MCG+12-10-010", + "Bode's Galaxy" + ], + "order": 5146, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -3.4114911797827514e+22, + 2.0588933098510244e+22, + 1.0415782317338e+23 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 509136800946075600000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m83/object.json b/src/objects/m83/object.json index c13a4295e67..7ee338ba145 100644 --- a/src/objects/m83/object.json +++ b/src/objects/m83/object.json @@ -36,7 +36,45 @@ }, "cataloguePoints": [ "dots" - ] + ], + "catalog": { + "name": "M83", + "systemName": "Galaxy catalogue", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 1010241333000, + "description": "A galaxy 4.9 million parsecs away.", + "aliases": [ + "ESO444-081", + "NGC5236", + "PGC048082", + "UGCA366" + ], + "order": 5147, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -1.1949184389051951e+23, + -5.382819619468973e+22, + -7.526278726357485e+22 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 286968015078697140000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m87/object.json b/src/objects/m87/object.json index 706d032b361..9dbed0de970 100644 --- a/src/objects/m87/object.json +++ b/src/objects/m87/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "M87", + "systemName": "Galaxy catalogue", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 3465248745000, + "description": "A galaxy 16.8 million parsecs away.", + "aliases": [ + "NGC 4486", + "Virgo A", + "VCC 1316" + ], + "order": 5148, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -5.017457335350851e+23, + -6.789157133212104e+22, + 1.1123902789140173e+23 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 1.238634295408084e+21, + "bodyRadiusM": 6.140498387167821e+21 } }, "prepared": { diff --git a/src/objects/ngc-253/object.json b/src/objects/ngc-253/object.json index f90ecba9c7f..1056d8845c7 100644 --- a/src/objects/ngc-253/object.json +++ b/src/objects/ngc-253/object.json @@ -36,7 +36,44 @@ }, "cataloguePoints": [ "dots" - ] + ], + "catalog": { + "name": "NGC 253", + "systemName": "Galaxy catalogue", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 715196092800, + "description": "A galaxy 3.5 million parsecs away.", + "aliases": [ + "ESO474-029", + "PGC002789", + "UGCA013" + ], + "order": 5149, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 9.466398987631915e+22, + 1.9928125630700205e+22, + -4.570390401431423e+22 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 614049838716782100000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/ngc-300/object.json b/src/objects/ngc-300/object.json index 508f9f7a32c..64a8a6162f0 100644 --- a/src/objects/ngc-300/object.json +++ b/src/objects/ngc-300/object.json @@ -36,7 +36,39 @@ }, "cataloguePoints": [ "dots" - ] + ], + "catalog": { + "name": "NGC 300", + "systemName": "Galaxy catalogue", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 430948261600, + "description": "A galaxy 2.1 million parsecs away.", + "order": 5150, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 4.956364387918276e+22, + 1.2103318163954808e+22, + -3.941065128390322e+22 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 290053692660188500000 + } }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/omega-centauri/object.json b/src/objects/omega-centauri/object.json index 8378d6e44db..d7acf3e2f80 100644 --- a/src/objects/omega-centauri/object.json +++ b/src/objects/omega-centauri/object.json @@ -33,6 +33,43 @@ }, "preparation": { "source": "source/delivery.json" + }, + "catalog": { + "name": "Omega Centauri", + "systemName": "Milky Way", + "classification": "globular-cluster", + "color": "#9a9a9a", + "distanceAu": 1119192839, + "description": "Omega Centauri is a globular star cluster in the Milky Way, about 5,426 parsecs away. That published distance locates the cluster, not its individual stars.", + "aliases": [ + "NGC 5139", + "ω Centauri", + "Caldwell 80" + ], + "order": 5151, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -105140549150265080000, + -41834040087509950000, + -123401250499199320000 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 811718046418199.9, + "bodyRadiusM": 1339334776590029800 } }, "prepared": { diff --git a/src/objects/smc/object.json b/src/objects/smc/object.json index 0afd8f4476c..fdb4f17fba4 100644 --- a/src/objects/smc/object.json +++ b/src/objects/smc/object.json @@ -33,6 +33,44 @@ }, "preparation": { "source": "source/compact-delivery.json" + }, + "catalog": { + "name": "SMC", + "systemName": "Local Group", + "classification": "galaxy", + "color": "#9a9a9a", + "distanceAu": 12879174500, + "description": "A galaxy 62,440 parsecs away.", + "aliases": [ + "Nubecula Minor", + "NGC 292", + "Small Magellanic Cloud", + "Small Milky Cloud" + ], + "order": 5152, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 558685771853032800000, + 162842052396096230000, + -1.8367130401501783e+21 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670000, + "bodyRadiusM": 123427103259654680000 } }, "prepared": { diff --git a/src/objects/virgo-cluster/object.json b/src/objects/virgo-cluster/object.json index 6e6261ca86e..d6842c32c1e 100644 --- a/src/objects/virgo-cluster/object.json +++ b/src/objects/virgo-cluster/object.json @@ -5,6 +5,42 @@ "properties": { "preparation": { "source": "source/dots/points.json" + }, + "catalog": { + "name": "Virgo Cluster", + "systemName": "Galaxy clusters", + "classification": "galaxy-cluster", + "color": "#9a9a9a", + "distanceAu": 3342143809000, + "description": "A cluster of galaxies 16.2 million parsecs away.", + "aliases": [ + "Virgo", + "MCXC J1230.7+1220" + ], + "order": 5153, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -4.840313484186915e+23, + -6.541779898547467e+22, + 1.0682585877607982e+23 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 5.0140062462525665e+22, + "bodyRadiusM": 5.0140062462525665e+22 } }, "prepared": { From 3be22a6152112e4468dd983fc2665fb40782dfeb Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 08:06:11 -0300 Subject: [PATCH 02/23] refactor(registry): a package the world's host draws is an entry built from its own descriptor, and a world object like any scene --- packages/bake/cli/prepare-solar-geometry.mts | 2 +- .../objects/src/node/catalog-directory.ts | 7 ++-- .../objects/src/node/prepared-registry.ts | 12 ++++-- packages/objects/src/registry/index.ts | 2 +- .../objects/src/registry/object-catalog.ts | 11 ++++-- .../objects/src/registry/object-schema.ts | 2 +- .../src/registry/prepared-focus-object.ts | 37 +++++++++++++++++-- site/build/prepare/prepare-catalog.mts | 16 ++++---- site/build/prepare/prepare-spatial-context.ts | 7 ++-- 9 files changed, 69 insertions(+), 27 deletions(-) diff --git a/packages/bake/cli/prepare-solar-geometry.mts b/packages/bake/cli/prepare-solar-geometry.mts index 709deb73c59..f446f04bb3e 100644 --- a/packages/bake/cli/prepare-solar-geometry.mts +++ b/packages/bake/cli/prepare-solar-geometry.mts @@ -61,7 +61,7 @@ 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) || (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}.`); diff --git a/packages/objects/src/node/catalog-directory.ts b/packages/objects/src/node/catalog-directory.ts index 769ee13d339..b9d57d00e03 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, isHostedDescriptor, overviewEntry } 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,11 +36,12 @@ 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. One + * with a catalogue entry is also an object of the registry, drawn by the world's host (isHostedDescriptor). */ 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 && !isHostedDescriptor(descriptor)) continue; if (descriptor.id !== name || typeof descriptor.type !== 'string') throw new TypeError(`Context object identity differs: ${name}.`); contexts.push({ id: name, type: descriptor.type }); } diff --git a/packages/objects/src/node/prepared-registry.ts b/packages/objects/src/node/prepared-registry.ts index ca02ff08425..0311fcf8be6 100644 --- a/packages/objects/src/node/prepared-registry.ts +++ b/packages/objects/src/node/prepared-registry.ts @@ -8,7 +8,7 @@ import { catalogueObject, defineObjects, isSceneObject } from '../registry/index import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, PreparedFocusObject } 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, then each package the world's host draws, 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', @@ -17,10 +17,11 @@ export const PREPARED_CATALOGUE = Object.freeze({ /** The catalogue module `prepare:catalog` writes at `PREPARED_CATALOGUE.entries`. */ export function preparedCatalogueModule(scenes: readonly { descriptor: unknown; distance: NavigationDistance; discovery: ObjectDiscovery }[], - focuses: readonly PreparedFocusObject[]) { + hosted: readonly { descriptor: unknown; distance: NavigationDistance; discovery: ObjectDiscovery; sceneHostId: string }[]) { // 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]; + const rows = [...scenes.map(({ descriptor, distance, discovery }) => ({ kind: 'scene', descriptor, distance, discovery })), + ...hosted.map(({ descriptor, distance, discovery, sceneHostId }) => ({ kind: 'prepared-focus', descriptor, distance, discovery, sceneHostId }))]; 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`; } @@ -35,6 +36,8 @@ export interface PreparedObjectRegistry { readonly objects: readonly PreparedNavigableObject[]; /** The scene objects, as `SCENE_OBJECTS` lists them. */ readonly sceneObjects: readonly PreparedSceneObject[]; + /** Every placed object the world draws: the scenes and the packages the world's host draws (galaxies, nebulae, clusters). */ + readonly worldObjects: readonly (PreparedSceneObject | PreparedFocusObject)[]; requireSceneObject(id: string): PreparedSceneObject; } @@ -68,7 +71,8 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { 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 worldObjects = Object.freeze(objects.filter((object): object is PreparedSceneObject | PreparedFocusObject => object.kind !== 'overview')); + return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects, 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.`); diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index d9c0d554af2..d975e411443 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -16,7 +16,7 @@ export { discoveryDescription, discoveryVisibility, isDiscoveryAnchor, offTheMap export type { ObjectDiscovery } from './object-discovery.js'; export { defineObject, defineObjects, 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 { definePreparedFocus, destinationSearchNames, HOSTED_OBJECT_TYPES, hostedObject, isHostedDescriptor, isSceneObject } from './prepared-focus-object.js'; export type { NavigableObject, PreparedFocusObject } from './prepared-focus-object.js'; export { defineOverview, overviewEntry, overviewHolding } from './overview-object.js'; export type { OverviewDistance, OverviewFrame, OverviewHolding, OverviewObject, OverviewZoom } from './overview-object.js'; diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index ce1494c497a..26161742d0d 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -5,7 +5,7 @@ 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 { hostedObject } from './prepared-focus-object.js'; import type { NavigableObject } from './prepared-focus-object.js'; import { defineOverview } from './overview-object.js'; @@ -78,9 +78,14 @@ export function catalogEntry(input: unknown, loadScene: ObjectDef 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.'); + if ((value.kind !== 'scene' && value.kind !== 'prepared-focus') || !isRecord(value.descriptor)) throw new TypeError('Invalid catalogue entry.'); + // A package the world's host draws: the same descriptor as a scene's, read into an entry with no scene of its own. + if (value.kind === 'prepared-focus') { + if (typeof value.sceneHostId !== 'string') throw new TypeError(`Invalid catalogue entry: ${String(value.descriptor.id)} names no scene host.`); + return hostedObject(catalogEntry(value.descriptor, async () => { throw new Error('A hosted object has no scene of its own.'); }, + parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)), value.sceneHostId); + } const { order: _order, context: _context, ...object } = catalogEntry(value.descriptor, loadScene(value.descriptor), parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)); return object; diff --git a/packages/objects/src/registry/object-schema.ts b/packages/objects/src/registry/object-schema.ts index 5887e2820fe..cc05691975f 100644 --- a/packages/objects/src/registry/object-schema.ts +++ b/packages/objects/src/registry/object-schema.ts @@ -118,7 +118,7 @@ function nonEmpty(value: unknown): value is string { } // Registry capability data is numeric; importing a renderer entry here would // pull its native camera factory into every otherwise unrelated object route. -function parseWorldFrame(value: unknown): ObjectWorldFrame { +export function parseWorldFrame(value: unknown): ObjectWorldFrame { const fields = ['referenceFrame', 'epochJdTt', 'originM', 'presentationToReference', 'metersPerUnit', 'bodyRadiusM', 'orbitUpReference']; const vector = (input: unknown, length: number): input is number[] => 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/prepared-focus-object.ts b/packages/objects/src/registry/prepared-focus-object.ts index 2100b1566f0..713e508461c 100644 --- a/packages/objects/src/registry/prepared-focus-object.ts +++ b/packages/objects/src/registry/prepared-focus-object.ts @@ -1,7 +1,11 @@ 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 { parseWorldFrame } from './object-schema.js'; +import type { ObjectEntry, ObjectWorldFrame } from './object-schema.js'; +import { parseObjectDiscovery } from './object-discovery.js'; +import type { ObjectDiscovery } from './object-discovery.js'; +import { normalizeDestinationQuery } from './destination-search.js'; import type { OverviewObject } from './overview-object.js'; export interface PreparedFocusObject { @@ -15,23 +19,48 @@ export interface PreparedFocusObject { readonly route: string; readonly sceneHostId: string; readonly distance: NavigationDistance; + /** As the package's catalogue block and world frame declare them: the world context places and names it like any body. */ + readonly color: string; + readonly description: string; + readonly worldFrame: ObjectWorldFrame; + readonly discovery: Readonly; } +/** The package types the world's host draws instead of mounting a scene of their own: image layers, a volume, catalogue points. */ +export const HOSTED_OBJECT_TYPES: readonly string[] = Object.freeze(['image-layer-bank', 'volume-dataset-bank', 'catalogue-point-bank']); +export const isHostedDescriptor = (descriptor: unknown): boolean => isRecord(descriptor) && typeof descriptor.type === 'string' && HOSTED_OBJECT_TYPES.includes(descriptor.type); /** 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. */ +/** The names a hosted 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(' ', '')]; +}))]; + +/** A package the world's host draws, from its own entry (its descriptor's catalogue block, world frame and discovery). It + * reuses the host scene and camera; it has no scene loader. */ +export function hostedObject(entry: Pick & { readonly aliases?: readonly string[] }, + sceneHostId: string): PreparedFocusObject { + return definePreparedFocus({ kind: 'prepared-focus', id: entry.id, focusId: entry.id, name: entry.name, + searchNames: destinationSearchNames([entry.id, entry.name, ...entry.aliases ?? []]), classification: entry.classification, + systemName: entry.systemName, route: `/${entry.id}/`, sceneHostId, distance: entry.distance, color: entry.color, + description: entry.description, worldFrame: entry.worldFrame, discovery: entry.discovery }); +} + 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)) || + if (!isRecord(input) || Object.keys(input).some(key => !['kind', 'id', 'focusId', 'name', 'searchNames', 'classification', 'systemName', 'route', 'sceneHostId', 'distance', 'color', 'description', 'worldFrame', 'discovery'].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 || + typeof input.color !== 'string' || !/^#[0-9a-f]{6}$/u.test(input.color) || typeof input.description !== 'string' || !input.description || !isRecord(input.worldFrame) || (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) }); + route: input.route, sceneHostId: input.sceneHostId, distance: parseNavigationDistance(input.distance), color: input.color, + description: input.description, worldFrame: parseWorldFrame(input.worldFrame), discovery: parseObjectDiscovery(input.discovery) }); } diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index 16186981109..337fadacecc 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -1,10 +1,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, relative, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { defineObjects, type CatalogEntry } from '@cssearth/objects'; +import { defineObjects, isHostedDescriptor, 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'; @@ -142,11 +142,10 @@ export async function prepareCatalog({ projectRoot = root } = {}) { 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.'); - // The spatial catalogues are data: they name no destination. Every page is a package's own entry. - const focuses: never[] = []; - // The levels above the star systems are drawn by the same shared world host as every catalogue focus. + // The spatial catalogues are data: they name no destination. Every page is a package's own entry: a scene, or a package + // the world's host draws (image layers, a volume, catalogue points), which the levels above the star systems are too. const overviews = await readOverviews(resolve(projectRoot, 'src/objects'), host.id, descriptors); - defineObjects<{ id: string; route: string }>([...entries, ...focuses, ...overviews]); + defineObjects<{ id: string; route: string }>([...entries, ...overviews]); const discoveries = await Promise.all(entries.map(async ({ id }) => { return prepareObjectDiscovery(descriptors.get(id), resolve(projectRoot, 'src/objects', id)); })); @@ -159,7 +158,10 @@ export async function prepareCatalog({ projectRoot = root } = {}) { 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)); + preparedCatalogueModule(...(() => { + const rows = entries.map(({ id, distance }, index) => ({ descriptor: descriptors.get(id), distance, discovery: discoveries[index]! })); + return [rows.filter(row => !isHostedDescriptor(row.descriptor)), rows.filter(row => isHostedDescriptor(row.descriptor)).map(row => ({ ...row, sceneHostId: host.id }))] as const; + })())); await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.overviews), JSON.stringify(overviews) + '\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'); diff --git a/site/build/prepare/prepare-spatial-context.ts b/site/build/prepare/prepare-spatial-context.ts index 0b134b3d9c2..b2e29589313 100644 --- a/site/build/prepare/prepare-spatial-context.ts +++ b/site/build/prepare/prepare-spatial-context.ts @@ -79,7 +79,8 @@ 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 { markerScale, LOCAL_GROUP_SCALE } = await import('@cssearth/renderer/labels/universe-label-policy.ts'); @@ -88,7 +89,7 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp 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]] : [])); + (entry.kind === 'scene' || entry.kind === 'prepared-focus') && isRecord(entry.descriptor) && typeof entry.descriptor.id === 'string' ? [[entry.descriptor.id, entry.discovery]] : [])); const present = async (body: Record) => { const object = byId.get(String(body.id)); if (!object) return; @@ -129,7 +130,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 From 260e947e7e5df31e4011e0913e9f8ac6a14f4d55 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 08:47:04 -0300 Subject: [PATCH 03/23] refactor: select galaxies, clusters and nebulae as world bodies, not catalogue rows --- packages/astronomy/src/bodies.test.ts | 12 + .../src/navigation/navigation-destinations.ts | 37 +-- packages/objects/src/image-layer-bank.ts | 4 +- .../src/node/prepared-registry.test.ts | 20 +- packages/objects/src/registry/index.ts | 2 +- .../objects/src/registry/object-catalog.ts | 5 +- .../objects/src/registry/object-discovery.ts | 4 +- .../objects/src/registry/object-schema.ts | 5 +- .../src/labels/universe-label-policy.ts | 3 +- .../src/sky/prepared-sky-runtime.test.ts | 16 +- .../renderer/src/styles/world-context.css | 16 +- .../src/universe/catalog-marker.test.ts | 40 --- .../renderer/src/universe/catalog-marker.ts | 44 --- .../universe/galaxy-catalog-layout.test.ts | 49 +-- .../src/universe/galaxy-catalog-layout.ts | 75 +---- .../universe/prepared-galaxy-catalog.test.ts | 304 ++---------------- .../src/universe/prepared-galaxy-catalog.ts | 269 ++-------------- .../src/universe/prepared-universe-runtime.ts | 44 +-- .../src/universe/universe-catalog-banks.ts | 24 +- .../universe/world-context/context-scale.ts | 11 + .../world-context-planner.test.ts | 9 +- .../world-context/world-context-planner.ts | 20 +- site/application-world-visibility.mts | 3 +- .../application-world-visibility.test.mts | 17 +- site/test/galactic-focus.test.mts | 62 ++-- site/test/navigation-ontology.test.mts | 27 +- site/test/prepared-world-context.test.ts | 13 +- src/objects/m45/object.json | 8 +- src/platform/solar-geometry.test.mts | 5 +- 29 files changed, 253 insertions(+), 895 deletions(-) delete mode 100644 packages/renderer/src/universe/catalog-marker.test.ts delete mode 100644 packages/renderer/src/universe/catalog-marker.ts 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/bake/src/navigation/navigation-destinations.ts b/packages/bake/src/navigation/navigation-destinations.ts index 2240d8d6178..74f49ee6e8c 100644 --- a/packages/bake/src/navigation/navigation-destinations.ts +++ b/packages/bake/src/navigation/navigation-destinations.ts @@ -1,10 +1,10 @@ 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 { parsePreparedGalaxyCatalog } from '@cssearth/catalog'; +import { parsePreparedClusterCatalog } from '@cssearth/catalog'; +import { parsePreparedNebulaCatalog } from '@cssearth/catalog'; import type { PreparedCatalogObject } from '@cssearth/catalog'; -import { defineObjects, definePreparedFocus, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { isHostedDescriptor, parseNavigationDistance } from '@cssearth/objects'; import { isRecord, hasErrorCode } from '@cssearth/core'; const AU_M = 149597870700, PC_M = 3.085677581491367e16; @@ -17,25 +17,18 @@ export function prepareSceneDistance(descriptor: unknown) { throw new TypeError('Navigation distance requires a prepared Sun-centred world frame.'); } const meters = Math.hypot(...frame.originM); + // A package the world's host draws (a galaxy, a cluster, a nebula) 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). + if (isHostedDescriptor(descriptor)) { + const catalog = isRecord(descriptor) && isRecord(descriptor.properties) ? descriptor.properties.catalog : null; + 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')); @@ -56,11 +49,3 @@ export async function readPreparedCatalogObjects(objectsDirectory: string) { } 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/objects/src/image-layer-bank.ts b/packages/objects/src/image-layer-bank.ts index 08ce959ab18..eef8bc605cc 100644 --- a/packages/objects/src/image-layer-bank.ts +++ b/packages/objects/src/image-layer-bank.ts @@ -16,7 +16,9 @@ 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.'); + // `catalog` and `worldFrame` are the package's registry entry and placement (prepared-focus-object.ts), read by the registry. + const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'catalog', 'worldFrame'].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/prepared-registry.test.ts b/packages/objects/src/node/prepared-registry.test.ts index 5711f2f2005..164645e74d9 100644 --- a/packages/objects/src/node/prepared-registry.test.ts +++ b/packages/objects/src/node/prepared-registry.test.ts @@ -17,9 +17,11 @@ 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 package the world's host draws: its own descriptor, as a scene's, with the scene that hosts it. +const hosted = (id: string, sceneHostId: string) => ({ descriptor: { ...descriptor(id, 9, 'nebula'), type: 'volume-dataset-bank' }, + distance: parseNavigationDistance(parsec), discovery: discovery(false), sceneHostId }); +const focus = hosted('helix', 'sun'); const overview = { kind: 'overview', id: 'milky-way', name: 'Milky Way', description: 'Our galaxy.', order: 1, zoom: { enter: { fade: 'system', at: 'end' }, returnBelow: { fade: 'system', at: 'middle' }, frame: { distance: { distancePc: 8000 } } }, @@ -29,7 +31,7 @@ const scene = (id: string, order: number, classification: string, distanceAu: nu ({ 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?: Parameters[0]; focuses?: Parameters[1]; 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,7 +41,7 @@ async function checkout(records: { scenes?: Parameters[1])); + preparedCatalogueModule(records.scenes ?? scenes, records.focuses ?? [focus])); await writeFile(join(root, PREPARED_CATALOGUE.overviews), JSON.stringify(records.overviews ?? [overview])); return root; } @@ -56,6 +58,10 @@ test('reads the registry in catalogue order, with distances, discoveries, prepar assert.ok(!('order' in mars) && !('context' in mars)); await assert.rejects(mars.loadScene(), /cannot mount a scene/); assert.throws(() => registry.requireSceneObject('helix'), /prepared focus/); + // A hosted package is a body of the world as a scene is, with its identity from its own descriptor. + assert.deepEqual(registry.worldObjects.map(object => object.id), ['sun', 'mars', 'helix']); + const helix = registry.objects.find(object => object.id === 'helix')!; + assert.partialDeepStrictEqual(helix, { kind: 'prepared-focus', classification: 'nebula', route: '/helix/', sceneHostId: 'sun', systemName: 'Solar System' }); assert.throws(() => registry.requireSceneObject('milky-way'), /is an overview, not a scene owner/); assert.throws(() => registry.requireSceneObject('pluto'), /Unknown/); }); @@ -69,8 +75,8 @@ test('refuses a catalogue it cannot decode or a focus without its host', async ( const cases: [Parameters[0], RegExp][] = [ [{ scenes: [...scenes, { ...scenes[1]!, descriptor: { id: 'pluto' } }] }, /Invalid catalogue descriptor/], [{ 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: [hosted('helix', 'mars'), hosted('m1', 'jupiter')] }, /not a registered scene: m1/], + [{ 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/], diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index d975e411443..7e9876795f9 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -14,7 +14,7 @@ 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, isPlacedClassification, OBJECT_CLASSIFICATIONS, PLACED_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, destinationSearchNames, HOSTED_OBJECT_TYPES, hostedObject, isHostedDescriptor, isSceneObject } from './prepared-focus-object.js'; export type { NavigableObject, PreparedFocusObject } from './prepared-focus-object.js'; diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index 26161742d0d..42a7c21f339 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -1,3 +1,4 @@ +import { parseDistanceSubject } from '@cssearth/catalog'; import type { ObjectDiscovery } from './object-discovery.js'; import { parseObjectDiscovery } from './object-discovery.js'; import { isRecord } from '@cssearth/core'; @@ -42,9 +43,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}.`); 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 cc05691975f..6bbbabc15e0 100644 --- a/packages/objects/src/registry/object-schema.ts +++ b/packages/objects/src/registry/object-schema.ts @@ -21,7 +21,10 @@ export type ObjectClassification = 'star' | 'planet' | 'satellite' | 'dwarf-plan | '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. */ -export const PLACED_CLASSIFICATIONS: readonly ObjectClassification[] = Object.freeze(['star', 'black-hole', 'galaxy', 'galaxy-cluster', 'nebula', 'globular-cluster']); +/** 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. */ diff --git a/packages/renderer/src/labels/universe-label-policy.ts b/packages/renderer/src/labels/universe-label-policy.ts index 2c9329461e5..b102f5a85c5 100644 --- a/packages/renderer/src/labels/universe-label-policy.ts +++ b/packages/renderer/src/labels/universe-label-policy.ts @@ -40,7 +40,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/sky/prepared-sky-runtime.test.ts b/packages/renderer/src/sky/prepared-sky-runtime.test.ts index 3419c06ca3c..1b65bb19d21 100644 --- a/packages/renderer/src/sky/prepared-sky-runtime.test.ts +++ b/packages/renderer/src/sky/prepared-sky-runtime.test.ts @@ -626,11 +626,14 @@ 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); + // 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'); + for (const id of ['focus-bank', 'image-bank', 'no-bank']) context.bodies.push({ ...nebula, id, name: id, positionM: frame.originM, radiusM: frame.metersPerUnit }); const image: PreparedCssImageLayers = { ...small, 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 }); @@ -643,13 +646,13 @@ test('authoritative detailed close-up gates background fetch, painting and publi 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, + const focus = (id = 'focus-bank'): PreparedNavigationFocus => ({ id, positionM: frame.originM, framingRadiusM: frame.metersPerUnit, limits: { minimumDistanceM: .01 * frame.metersPerUnit, maximumDistanceM: 1e25 } }); 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()); + mounted.selectGalaxy('focus-bank', focus()); 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']); @@ -686,7 +689,7 @@ 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); + mounted.selectGalaxy('image-bank', focus('image-bank')); mounted.publish(near, viewport, spatialFrame); assert.equal(imageRoot.style.display, ''); assert.equal(findBank('focus-bank').style.display, 'none'); mounted.selectGalaxy(null); mounted.publish(near, viewport, spatialFrame); assert.equal(mw.style.display, ''); @@ -694,8 +697,9 @@ test('authoritative detailed close-up gates background fetch, painting and publi 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); + mounted.selectGalaxy('no-bank', focus('no-bank')); mounted.publish(near, viewport, spatialFrame); assert.equal(mw.style.display, ''); - assert.throws(() => mounted.selectGalaxy('catalogue:focus-bank', focus('mismatch')), /focus/); + assert.throws(() => mounted.selectGalaxy('focus-bank', focus('mismatch')), /focus/); + assert.throws(() => mounted.selectGalaxy('catalogue-row', focus('catalogue-row')), /not a body of the prepared world context/); } 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/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/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..a03acf343f6 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 { isPreparedCluster, 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,47 @@ 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); - }, + /** The catalogue row `id` names, as data for whoever presents it. */ 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 7dc84365d33..b7fef116d9b 100644 --- a/packages/renderer/src/universe/prepared-universe-runtime.ts +++ b/packages/renderer/src/universe/prepared-universe-runtime.ts @@ -199,12 +199,14 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol const shellLayers: ReturnType[] = []; const mountedShells = [...shells]; let selected = plan.focus; + // The mounted scene's own body: a package the host draws is selected over it, and clearing that returns to it. + let sceneBody: typeof selected = selected; // The caption sits below the selected body's longest reach, which an elongated shape model extends past its radius. let captionBody: typeof selected = selected; 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 }); + const captionFlags = () => ({ overview: selectionPreview ? false : overview, focused: false, 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; @@ -244,20 +246,27 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol 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; + // A package the host draws is a body of this world: selecting it selects that body, as a scene's page selects its own. + const body = id === null ? sceneBody : plan.bodies.find(candidate => candidate.id === id); + if (!body) throw new TypeError(`Hosted object ${id} is not a body of the prepared world context.`); + const next = focus && id !== null && [...declaredImageLayers, ...volumeDatasetBanks].some(bank => bank.id === id) + ? { objectId: id, 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; + // A package of catalogue dots draws them while it is selected, without the detailed focus's context fade. + const points = id !== null && pointBankIds.has(id) ? id : undefined; const pointsChanged = points !== selectedPoints; selectedPoints = points; - catalogBanks.catalog?.select(id); - if (pointsChanged) requestPublication?.(); - if (changed) requestPublication?.(); + const bodyChanged = body !== selected; + if (bodyChanged) { + selected = body; captionBody = body; selectedEdge = undefined; + selectedLabel.prepare(caption()); + spatial.selectObject(body.id); + publishSuppressedLabels(); + root.dataset.selectedObject = body.id; + } + if (pointsChanged || changed || bodyChanged) requestPublication?.(); }, resolveGalaxy(id: string) { return catalogBanks.catalog?.resolve(id) ?? null; }, ensureGalaxyCatalog: catalogBanks.ensureCatalog, @@ -291,7 +300,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); }, @@ -310,7 +318,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol throw new TypeError('Selected detail does not match its prepared world context.'); } if (!(framingScale > 0 && framingScale <= 1)) throw new TypeError(`Selected ${id} has an invalid framing scale ${framingScale}.`); - selected = body; + selected = sceneBody = body; selectedEdge = edge; captionBody = framingScale === 1 ? body : Object.freeze({ ...body, radiusM: body.radiusM / framingScale }); selectedLabel.prepare(caption()); @@ -325,7 +333,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol const detailContextOpacity = detailedFocusContextOpacity(world, detailedFocus?.focus ?? null); if (detailContextOpacity > 0) background.prefetch(distanceM); const outsideGalaxy = galaxyOutsideFade( - Math.hypot(...world.pose.positionM.map((value, axis) => value - selected.positionM[axis]!)), plan.volume.discHalfHeightM); + Math.hypot(...world.pose.positionM.map((value, axis) => value - sceneBody.positionM[axis]!)), plan.volume.discHalfHeightM); additionalPoints.publish({world, viewport}, distanceM, outsideGalaxy); // Past halfway out the galaxy is seen whole, as the universe background draws it (universe-background.ts). spatial.setOutsideGalaxy(outsideGalaxy > .5); @@ -337,7 +345,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol for (const mesh of meshes) mesh.runtime.setHidden(mesh.hidden()); const meshCover = Math.max(0, ...meshes.map(mesh => mesh.runtime.publish({ world, viewport }, logarithmicFade(distanceM, IMAGE_MESH_LOAD_DISTANCE_M / 2, IMAGE_MESH_LOAD_DISTANCE_M), meshCaptioned))); const fade = logarithmicFade(distanceM, plan.volume.fadeStartDistanceM, plan.volume.fullDistanceM); - const volumeOpacity = background.publish(world, viewport, distanceM, selected.positionM, detailContextOpacity); + const volumeOpacity = background.publish(world, viewport, distanceM, sceneBody.positionM, detailContextOpacity); catalogBanks.publishImages(world, viewport, volumeOpacity, detailedFocus?.objectId); catalogBanks.publishPoints(world, viewport, selectedPoints); datasets.publish(world, viewport, volumeOpacity, detailContextOpacity, detailedFocus?.objectId, @@ -356,13 +364,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/universe-catalog-banks.ts b/packages/renderer/src/universe/universe-catalog-banks.ts index 03efc3625bf..0f0028eedd8 100644 --- a/packages/renderer/src/universe/universe-catalog-banks.ts +++ b/packages/renderer/src/universe/universe-catalog-banks.ts @@ -48,8 +48,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 => { @@ -98,24 +96,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(); } @@ -154,11 +137,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, 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 f9f85f0c4de..d94eda7da3a 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'; @@ -750,13 +751,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 f60e798848b..2fa73b43537 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,13 +99,14 @@ 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])); + // An extended body (a galaxy, a cluster, a nebula) is a point here: its framing radius is no sphere that hides or resolves. + 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) : []; - return { body, orbit, levels, parent: orbit ? byId.get(orbit.centerBodyId) ?? null : null, scale: markerScale(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(Math.hypot(...body.positionM.map((value, axis) => value - plan.focus.positionM[axis]!))), 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. @@ -161,7 +163,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 @@ -260,7 +262,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. @@ -342,12 +344,12 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW // 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); + const beyondLocalGroup = extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(Math.hypot(...eye), entry.scale.returnDistanceM, entry.scale.enterDistanceM); // The focus, a featured star, a star beyond the Local Group and a black hole keep their dots; planets fade with their system. const galaxyHost = body.id !== plan.focus.id && entry.orbit === null && body.classification === 'star' && entry.scale === LOCAL_GROUP_SCALE && (annotationPriorities[body.id] ?? 0) < FEATURED_STAR_TIER; // A retired galaxy level takes its own stars with it; a body beyond the Local Group belongs to the cluster scale. - const atGalaxyScale = view.galaxyRetired === true && body.id !== plan.focus.id && entry.orbit === null && entry.scale === LOCAL_GROUP_SCALE ? 1 : galaxyHost ? galaxyHandoff : 0; + const atGalaxyScale = view.galaxyRetired === true && body.id !== plan.focus.id && entry.orbit === null && entry.scale === LOCAL_GROUP_SCALE && entry.kind !== 'galaxy' ? 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)); @@ -356,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, @@ -432,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/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[0]; - const publications: Parameters[0][] = [], categories: (string | null)[] = []; - const visibility = createApplicationWorldVisibility({ setBodyVisibility(value) { publications.push(value); }, - setHighlightedClassification(value) { categories.push(value); } }, lifetime); - return { visibility, publications, categories, latest: () => publications.at(-1)! }; + const publications: Parameters[0][] = []; + const visibility = createApplicationWorldVisibility({ setBodyVisibility(value) { publications.push(value); } }, lifetime); + return { visibility, publications, latest: () => publications.at(-1)! }; } test('every satellite family is visible when its host or any member is selected', () => { @@ -61,8 +60,8 @@ test('only an explicitly selected non-featured comet gains its default-hidden or assert.ok(latest().orbitHidden?.includes('comet-2p')); }); -test('a pill category highlights its bodies and reaches the galaxy, cluster and nebula catalogue too', () => { - const { visibility, categories, latest } = fixture(); +test('a pill category highlights its bodies, the galaxies among them', () => { + const { visibility, latest } = fixture(); visibility.setHighlightedClassification('planet'); assert.ok(latest().highlighted?.includes('jupiter')); assert.ok(latest().highlighted?.includes('pluto'), 'the Planets pill marks the dwarf planets too'); @@ -73,7 +72,9 @@ test('a pill category highlights its bodies and reaches the galaxy, cluster and assert.ok(latest().highlighted?.includes('hr-8799'), 'from afar a planet is its star\'s dot, so the star carries the mark'); assert.ok(!latest().highlighted?.includes('sun')); visibility.setHighlightedClassification('galaxy'); - assert.deepEqual(latest().highlighted, []); + // A galaxy with a package is a body of the world: the pill marks it as it marks a planet. + for (const id of ['m31', 'lmc', 'm87']) assert.ok(latest().highlighted?.includes(id), id); + assert.ok(!latest().highlighted?.includes('jupiter')); visibility.setHighlightedClassification(null); - assert.deepEqual(categories, ['planet', 'exoplanet', 'galaxy', null]); + assert.deepEqual(latest().highlighted, []); }); diff --git a/site/test/galactic-focus.test.mts b/site/test/galactic-focus.test.mts index ce5b08ccfa6..dae6b1c890f 100644 --- a/site/test/galactic-focus.test.mts +++ b/site/test/galactic-focus.test.mts @@ -1,40 +1,30 @@ 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: 'volume-dataset-bank', 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 hosted package'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({ kind: 'prepared-focus', descriptor, distance, discovery: { featured: false, imagery: true, illustration: false }, sceneHostId: 'sun' }, + () => async () => { throw new Error('unused'); }); + assert.ok(focus.kind === 'prepared-focus'); + assert.equal(focus.classification, 'globular-cluster'); + assert.equal(focus.systemName, 'Milky Way'); + assert.equal(focus.route, '/test-cluster/'); + assert.equal(focus.sceneHostId, 'sun'); + 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); }); diff --git a/site/test/navigation-ontology.test.mts b/site/test/navigation-ontology.test.mts index 626997472db..73e81b8de53 100644 --- a/site/test/navigation-ontology.test.mts +++ b/site/test/navigation-ontology.test.mts @@ -5,17 +5,20 @@ import { readFile } from 'node:fs/promises'; import { OBJECTS, SCENE_OBJECTS, requireObject, 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, isHostedDescriptor, 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'); +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 hosted package's entry is its own descriptor's, as a scene's is: no catalogue row names a destination. + const hosted = [...descriptors].filter(([, descriptor]) => isHostedDescriptor(descriptor) && isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.catalog !== undefined).map(([id]) => id); 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.equal(OBJECTS.length, SCENE_OBJECTS.length + hosted.length + overviews.length); + assert.deepEqual(OBJECTS.filter(object => object.kind === 'prepared-focus').map(object => object.id).sort(), hosted.sort()); 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))); @@ -27,6 +30,11 @@ test('every prepared spatial subject and every scene has exactly one searchable assert.equal(object.kind, 'prepared-focus'); assert.ok(object.kind === 'prepared-focus' && 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(object.kind === 'prepared-focus' && isExtendedClassification(object.classification) && object.worldFrame && Math.hypot(...object.worldFrame.originM) > 0, id); + } }); test('distance display and order use the prepared position, never the legacy orbital reference', async () => { @@ -63,9 +71,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,7 +80,7 @@ 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'); diff --git a/site/test/prepared-world-context.test.ts b/site/test/prepared-world-context.test.ts index d3dd92078a0..1008ddaaa81 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -18,7 +18,8 @@ import { createWorldContextFrameEncoder } from '../../packages/renderer/src/univ import { type PackedWorldContextView, unpackWorldBodies } from '../../packages/renderer/src/universe/world-context/world-context-view-transport.js'; 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 { OBJECTS, 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, and the packages the host draws (galaxies, clusters, nebulae). + const worldObjects = OBJECTS.filter(object => object.kind !== 'overview'); + 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/src/objects/m45/object.json b/src/objects/m45/object.json index 0a63209a8cb..fc4f49e9875 100644 --- a/src/objects/m45/object.json +++ b/src/objects/m45/object.json @@ -47,7 +47,13 @@ "Pleiades reflection nebula" ], "order": 5144, - "context": {} + "context": {}, + "distanceSubject": { + "id": "m45-stellar-cluster", + "name": "Pleiades stellar cluster", + "relationship": "Associated illuminating stellar cluster", + "reason": "The stellar-cluster distance locates the reflection-nebula scene; individual dust-filament distances are not measured." + } }, "worldFrame": { "referenceFrame": "sun-icrf", diff --git a/src/platform/solar-geometry.test.mts b/src/platform/solar-geometry.test.mts index c14ae6c5c99..616ba32b775 100644 --- a/src/platform/solar-geometry.test.mts +++ b/src/platform/solar-geometry.test.mts @@ -14,7 +14,6 @@ import { parseSolidReplayScene } from '@cssearth/bake/objects/layers/terrestrial import { requireObjectRuntimeDefinition } from '@cssearth/bake/contract'; import { readPreparedObjects } from '@cssearth/objects/node'; -const SCENE_OBJECTS = readPreparedObjects(resolve(import.meta.dirname, '../..')).sceneObjects; type Vector = readonly number[]; type EpochStateSource = { centerBodyId: string; positionKm: Vector; parentHeliocentricState: { positionKm: Vector; velocityKmPerDay: Vector } }; @@ -86,8 +85,8 @@ test('all retained body centers are composed with their named parent at the fixe }); test('regeneration retains every current registry orbit, including moons and comets', async () => { - const registry = SCENE_OBJECTS.filter(body => - ['planet', 'dwarf-planet', 'satellite', 'asteroid', 'trans-neptunian', 'interstellar', 'comet', 'exoplanet', 'star', 'black-hole'].includes(body.classification) && body.id !== 'sun').map(body => body.id); + // Every body of the world but the Sun: the scenes, and the galaxies, clusters and nebulae the host draws. + const registry = readPreparedObjects(resolve(import.meta.dirname, '../..')).worldObjects.filter(body => body.id !== 'sun').map(body => body.id); // Then every star or planet on a hosted orbit around a packaged host, drawn from its astronomy record alone. const { BODIES, HOSTED_PLANET_IDS } = await import('@cssearth/astronomy'); const records = BODIES as Readonly>; From a22678b608c074191f2e602c071f88c475b84a5e Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 09:00:31 -0300 Subject: [PATCH 04/23] feat(universe): the Local Group has its own marker among the galaxy clusters --- .gitignore | 1 + .../astronomy/data/bodies/local-group.json | 28 ++++++++++++++++ .../src/navigation/navigation-destinations.ts | 4 +-- .../src/node/prepared-registry.test.ts | 8 ++++- .../objects/src/node/prepared-registry.ts | 9 ++++- .../world-context-planner.test.ts | 6 ++-- site/build/prepare/prepare-catalog.mts | 7 +++- site/build/prepare/prepare-spatial-context.ts | 1 + site/test/prepared-world-context.test.ts | 6 ++-- src/objects/local-group/README.md | 1 + src/objects/local-group/object.json | 33 +++++++++++++++++++ src/objects/sun/inventory.json | 14 +++++--- 12 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 packages/astronomy/data/bodies/local-group.json diff --git a/.gitignore b/.gitignore index 0de63e64628..3ece45f7fee 100644 --- a/.gitignore +++ b/.gitignore @@ -269,6 +269,7 @@ src/objects/*/source/**/*.bpc /site/prepared-facilities.json /site/prepared-catalogue.mjs /site/prepared-overview-objects.json +/site/prepared-level-bodies.json /site/prepared-dataset-volumes.json /site/prepared-stellar-extents.json /site/prepared-local-group-galaxies.json 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/bake/src/navigation/navigation-destinations.ts b/packages/bake/src/navigation/navigation-destinations.ts index 74f49ee6e8c..725e8f0ad9a 100644 --- a/packages/bake/src/navigation/navigation-destinations.ts +++ b/packages/bake/src/navigation/navigation-destinations.ts @@ -17,9 +17,9 @@ export function prepareSceneDistance(descriptor: unknown) { throw new TypeError('Navigation distance requires a prepared Sun-centred world frame.'); } const meters = Math.hypot(...frame.originM); - // A package the world's host draws (a galaxy, a cluster, a nebula) sits where its catalogued distance puts it: that distance + // A package the world's host draws (a galaxy, a cluster, 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). - if (isHostedDescriptor(descriptor)) { + if (isHostedDescriptor(descriptor) || (isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.overview !== undefined)) { const catalog = isRecord(descriptor) && isRecord(descriptor.properties) ? descriptor.properties.catalog : null; 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 }); diff --git a/packages/objects/src/node/prepared-registry.test.ts b/packages/objects/src/node/prepared-registry.test.ts index 164645e74d9..0c08e4f0505 100644 --- a/packages/objects/src/node/prepared-registry.test.ts +++ b/packages/objects/src/node/prepared-registry.test.ts @@ -31,7 +31,7 @@ const scene = (id: string, order: number, classification: string, distanceAu: nu ({ 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?: Parameters[1]; module?: string; overviews?: unknown } = {}) { +async function checkout(records: { scenes?: Parameters[0]; focuses?: Parameters[1]; module?: string; overviews?: unknown; levelBodies?: 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) { @@ -43,6 +43,7 @@ async function checkout(records: { scenes?: Parameters object.id), ['sun', 'mars', 'helix']); const helix = registry.objects.find(object => object.id === 'helix')!; + // A level that is a body too joins the world's objects; its registry entry stays its overview. + const placed = readPreparedObjects(await checkout({ levelBodies: [{ kind: 'prepared-focus', ...hosted('milky-way', 'sun') }] })); + assert.deepEqual(placed.worldObjects.map(object => object.id), ['sun', 'mars', 'helix', 'milky-way']); + assert.equal(placed.objects.find(object => object.id === 'milky-way')?.kind, 'overview'); assert.partialDeepStrictEqual(helix, { kind: 'prepared-focus', classification: 'nebula', route: '/helix/', sceneHostId: 'sun', systemName: 'Solar System' }); assert.throws(() => registry.requireSceneObject('milky-way'), /is an overview, not a scene owner/); assert.throws(() => registry.requireSceneObject('pluto'), /Unknown/); @@ -80,6 +85,7 @@ test('refuses a catalogue it cannot decode or a focus without its host', async ( [{ 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/], + [{ levelBodies: [{ kind: 'prepared-focus', ...hosted('m1', 'sun') }] }, /Level body is not a registered overview: m1/], ]; for (const [records, error] of cases) { const root = await checkout(records); diff --git a/packages/objects/src/node/prepared-registry.ts b/packages/objects/src/node/prepared-registry.ts index 0311fcf8be6..6402283cb5a 100644 --- a/packages/objects/src/node/prepared-registry.ts +++ b/packages/objects/src/node/prepared-registry.ts @@ -13,6 +13,9 @@ import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, export const PREPARED_CATALOGUE = Object.freeze({ entries: 'site/prepared-catalogue.mjs', overviews: 'site/prepared-overview-objects.json', + /** The levels that are also bodies of the world (the Local Group, marked among the clusters): each one's descriptor with + * its distance and discovery, as a hosted package's row. Its registry entry stays its overview. */ + levelBodies: 'site/prepared-level-bodies.json', }); /** The catalogue module `prepare:catalog` writes at `PREPARED_CATALOGUE.entries`. */ @@ -71,7 +74,11 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { 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}`); } - const worldObjects = Object.freeze(objects.filter((object): object is PreparedSceneObject | PreparedFocusObject => object.kind !== 'overview')); + const levelRows: unknown = load(resolve(checkout, PREPARED_CATALOGUE.levelBodies)); + if (!Array.isArray(levelRows)) throw new TypeError(`Invalid prepared level bodies: ${PREPARED_CATALOGUE.levelBodies}.`); + const levelBodies = levelRows.map(row => catalogueObject(row, refuse)).filter((body): body is PreparedFocusObject => body.kind === 'prepared-focus'); + for (const body of levelBodies) if (objects.find(object => object.id === body.id)?.kind !== 'overview') throw new TypeError(`Level body is not a registered overview: ${body.id}`); + const worldObjects = Object.freeze([...objects.filter((object): object is PreparedSceneObject | PreparedFocusObject => object.kind !== 'overview'), ...levelBodies]); return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects, worldObjects, requireSceneObject(id: string) { const object = objects.find(candidate => candidate.id === id); if (!object) throw new Error(`Unknown cssEarth object: ${id}`); 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 d94eda7da3a..2fa4dfbe21e 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 @@ -737,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', () => { diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index 337fadacecc..0b6da39b081 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -139,7 +139,10 @@ 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 catalogued = await readCatalog(resolve(projectRoot, 'src/objects'), prepareSceneDistance, descriptors); + // A level that is also a body of the world (the Local Group among the clusters) keeps its overview as its registry entry. + const authorsOverview = (id: string) => { const descriptor = descriptors.get(id); return isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.overview !== undefined; }; + const entries = catalogued.filter(entry => !authorsOverview(entry.id)), levels = catalogued.filter(entry => authorsOverview(entry.id)); 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.'); // The spatial catalogues are data: they name no destination. Every page is a package's own entry: a scene, or a package @@ -163,6 +166,8 @@ export async function prepareCatalog({ projectRoot = root } = {}) { return [rows.filter(row => !isHostedDescriptor(row.descriptor)), rows.filter(row => isHostedDescriptor(row.descriptor)).map(row => ({ ...row, sceneHostId: host.id }))] as const; })())); await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.overviews), JSON.stringify(overviews) + '\n'); + await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.levelBodies), JSON.stringify(levels.map(({ id, distance }) => ({ kind: 'prepared-focus', + descriptor: descriptors.get(id), distance, discovery: { featured: true, imagery: false, illustration: false }, sceneHostId: host.id }))) + '\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-spatial-context.ts b/site/build/prepare/prepare-spatial-context.ts index b2e29589313..125419e385f 100644 --- a/site/build/prepare/prepare-spatial-context.ts +++ b/site/build/prepare/prepare-spatial-context.ts @@ -90,6 +90,7 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp // 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' || entry.kind === 'prepared-focus') && isRecord(entry.descriptor) && typeof entry.descriptor.id === 'string' ? [[entry.descriptor.id, entry.discovery]] : [])); + for (const object of registry) if (!Object.hasOwn(discoveries, object.id)) discoveries[object.id] = object.discovery; const present = async (body: Record) => { const object = byId.get(String(body.id)); if (!object) return; diff --git a/site/test/prepared-world-context.test.ts b/site/test/prepared-world-context.test.ts index 1008ddaaa81..1c367ed7881 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -18,7 +18,7 @@ import { createWorldContextFrameEncoder } from '../../packages/renderer/src/univ import { type PackedWorldContextView, unpackWorldBodies } from '../../packages/renderer/src/universe/world-context/world-context-view-transport.js'; 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 { OBJECTS, SCENE_OBJECTS } from '../objects.mts'; +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'; @@ -762,8 +762,8 @@ 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. - // Every body of the world: the scenes, and the packages the host draws (galaxies, clusters, nebulae). - const worldObjects = OBJECTS.filter(object => object.kind !== 'overview'); + // 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')); diff --git a/src/objects/local-group/README.md b/src/objects/local-group/README.md index 8640a0aacdc..ae279186d02 100644 --- a/src/objects/local-group/README.md +++ b/src/objects/local-group/README.md @@ -29,6 +29,7 @@ The [source manifest](source/manifest.json) lists every input and authored recor - Host-assigned, numerical-action, redshift/Hubble-law distances, confirmed clusters and false positives are excluded. A blank distance method stays `unspecified`. - Membership uses 143 explicit name bindings to the updated table's 144 rows; Canis Major has no matching LVDB row. No coordinates are used to guess a match. G/A/L mean Milky Way, M31 or other Local Group association and N a nearby neighbour; mixed codes such as G/L keep the uncertainty. An unmatched field galaxy stays `uncertain`. - Half-light radius is the projected **semi-major radius** at the adopted distance. Its errors carry angular-fit errors only. It is not a diameter or a hard edge. +- The group's own marker, shown among the galaxy clusters from 3 to 10 Mpc out, sits at the mid-point of the vector connecting the Milky Way and M31, where [McConnachie (2012), AJ 144, 4](https://arxiv.org/abs/1204.1562) places the Local Group's barycentre (Table 2, notes to columns 8 to 10). It is computed from this repository's positions of the Galactic centre and M31, so the paper's adopted M31 modulus (24.47) is not used. It is framed at the paper's zero-velocity surface radius, 1060 ± 70 kpc (section 3). The [astronomy record](../../../packages/astronomy/data/bodies/local-group.json) holds the values. - The four detailed galaxies have authored arrival framing radii of 25, 9, 5 and 4 kpc (M31, M33, LMC, SMC). These are not measured galaxy radii. The command also writes `prepared/display-sample.json`. The [sampling recipe](source/presentation.json) picks 48 catalogue-only Local Group galaxies from 150 kpc cells, weighted by square-root counts. Dots start appearing at 120 Mpc and are fully visible at 30 Mpc; names and ovals follow between 40 and 12 Mpc, with at most twelve labels. Catalogue-only entries stay navigation destinations whether or not they are sampled. diff --git a/src/objects/local-group/object.json b/src/objects/local-group/object.json index 00897a77385..b86d387ea4a 100644 --- a/src/objects/local-group/object.json +++ b/src/objects/local-group/object.json @@ -35,6 +35,39 @@ }, "preparation": { "source": "source/catalogue.json" + }, + "catalog": { + "name": "Local Group", + "systemName": "Galaxy clusters", + "classification": "galaxy-cluster", + "classificationLabel": "Galaxy group", + "color": "#9a9a9a", + "distanceAu": 79648329870.0, + "description": "Our galaxy group, including the Milky Way, Andromeda and their neighboring galaxies.", + "order": 5154, + "context": {} + }, + "worldFrame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 8.839012629123569e+21, + 1.5574334255884387e+21, + 7.837010763892267e+21 + ], + "presentationToReference": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 3.2708182363808494e+22, + "bodyRadiusM": 3.2708182363808494e+22 } }, "prepared": { diff --git a/src/objects/sun/inventory.json b/src/objects/sun/inventory.json index e3a9f483f9b..a7ff2ac536e 100644 --- a/src/objects/sun/inventory.json +++ b/src/objects/sun/inventory.json @@ -211,6 +211,12 @@ "bytes": 1320, "sha256": "6ff26038d012dd2df122b0d606ac7678e77b4501c04d1bf2307e5475a0046b7d" }, + { + "location": "prepared", + "filename": "provenance.json", + "bytes": 89168, + "sha256": "9a7a704b2c3b11451e430871f24b5f6054d9f9b85fa92ecc2c13e42293acc6be" + }, { "location": "prepared", "filename": "runtime.json", @@ -4618,14 +4624,14 @@ { "location": "prepared", "filename": "world-context-summary.json", - "bytes": 861197, - "sha256": "0464bfe6fcb34cfac371320dd7bdf375be700a54d65ab8547e3b32482b61e424" + "bytes": 864946, + "sha256": "53960dd2d7d590bd1536aff0e6a272a8e49f99d333176a8672bd8b66cc6427b1" }, { "location": "prepared", "filename": "world-context.json", - "bytes": 52804231, - "sha256": "79263ae7dca01dd9271717d8ba6ca78303998a2279c88635ec1b418e15c00a35" + "bytes": 53090532, + "sha256": "90ba732a2b9e20921ac0241bdefe1c2c5cf67d092a1a9025e67fb33cf9583c89" }, { "location": "prepared", From 7510ee44fe970aaf1546419811a593db7bcc0cf9 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 09:00:39 -0300 Subject: [PATCH 05/23] docs(local-group): the marker's capture --- src/objects/local-group/README.md | 4 ++++ .../evidence/2026-10-01/local-group-marker.jpg | Bin 0 -> 134363 bytes 2 files changed, 4 insertions(+) create mode 100644 src/objects/local-group/evidence/2026-10-01/local-group-marker.jpg diff --git a/src/objects/local-group/README.md b/src/objects/local-group/README.md index ae279186d02..49c557ae0b7 100644 --- a/src/objects/local-group/README.md +++ b/src/objects/local-group/README.md @@ -38,6 +38,10 @@ The command also writes `prepared/display-sample.json`. The [sampling recipe](so The [catalogue tests](../../../packages/bake/src/galaxy-catalog/galaxy-catalog.test.ts) regenerate the catalogue, display sample and receipt and compare them byte for byte. They check coordinate axes independently, keep the four detailed galaxies' published directions and distances, and reject corrupted sources, coordinate-frame changes, radius-based membership and lost candidates. A pypdf 5.9.0 extraction checks the Table 1 transcription against the original PDF. These checks establish derivation, not visual or scientific acceptance. +![The Local Group's marker between the Virgo and Fornax clusters, 100 Mpc out](evidence/2026-10-01/local-group-marker.jpg) + +A headless capture of this version's Nearby Universe page on the local preview: the Local Group's marker and name stand with the two clusters that have packages. + ## Known problems - The display sample is incomplete by design and is not a density, completeness or mass map. diff --git a/src/objects/local-group/evidence/2026-10-01/local-group-marker.jpg b/src/objects/local-group/evidence/2026-10-01/local-group-marker.jpg new file mode 100644 index 0000000000000000000000000000000000000000..32233d6d83957d2d53b8817dd054be7808cc92ee GIT binary patch literal 134363 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

AYuNktUCO2S5H7Ze4|Ki&RJ?GS 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 literal 0 HcmV?d00001 From 68029c9670ab8a4142b64c15542a1a91a00a866b Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 09:01:11 -0300 Subject: [PATCH 06/23] docs(local-group): the view before --- src/objects/local-group/README.md | 4 ++++ .../2026-10-01/before-catalogue-labels.jpg | Bin 0 -> 144711 bytes 2 files changed, 4 insertions(+) create mode 100644 src/objects/local-group/evidence/2026-10-01/before-catalogue-labels.jpg diff --git a/src/objects/local-group/README.md b/src/objects/local-group/README.md index 49c557ae0b7..ec6ddd57a88 100644 --- a/src/objects/local-group/README.md +++ b/src/objects/local-group/README.md @@ -42,6 +42,10 @@ The [catalogue tests](../../../packages/bake/src/galaxy-catalog/galaxy-catalog.t A headless capture of this version's Nearby Universe page on the local preview: the Local Group's marker and name stand with the two clusters that have packages. +![Before: the same view named seven clusters from catalogue rows, with no Local Group](evidence/2026-10-01/before-catalogue-labels.jpg) + +The version before this one, same view: five clusters named from catalogue rows alone (Centaurus, Norma and three out of frame) and no Local Group. Those five have no package, so they are no longer named or listed. + ## Known problems - The display sample is incomplete by design and is not a density, completeness or mass map. diff --git a/src/objects/local-group/evidence/2026-10-01/before-catalogue-labels.jpg b/src/objects/local-group/evidence/2026-10-01/before-catalogue-labels.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4200f0c2d9c962c1900d74259146ab3275e846e5 GIT binary patch literal 144711 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 ZSNq8l%R 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 hlXy)GdrOT>bkQO-iwBX>ib7vmfu;oYR Date: Thu, 1 Oct 2026 09:09:02 -0300 Subject: [PATCH 07/23] fix(shell): one gap above and below every card divider --- site/shell/maps-shell.css | 7 +++++-- site/shell/shell-layout.css | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/site/shell/maps-shell.css b/site/shell/maps-shell.css index a6019a9fc7b..eeabd8db08c 100644 --- a/site/shell/maps-shell.css +++ b/site/shell/maps-shell.css @@ -631,6 +631,8 @@ .object-chart-figure > .object-chart { margin: 0; } .object-mobile-information-heading { display: none; } @media (min-width: 821px) and (orientation: landscape) { + /* The tabs' own padding is the gap under the header's divider (--explorer-divider-gap); the header adds none. */ + .object-card > .object-selected-panel:has(+ :is(.object-card-tabs, .object-tabs)) { padding-bottom: 0; } .object-information-panel > .object-factsheet-content { padding-top: 8px; } .object-information-panel:has(> .object-card-tabs) > .object-dataset-content.atlas-tree-disclosure { padding-top: 8px; border-top: 0; } .object-information-panel:has(> .object-card-tabs) > .object-dataset-content > summary { display: none; } @@ -732,9 +734,10 @@ .object-dataset-content > .object-dataset-details { position: relative; width: auto; - margin-top: 8px; + /* The dataset rows end 4px above this box and its title's line starts 1px inside its padding. */ + margin-top: calc(var(--explorer-divider-gap) - 2px); margin-inline: 0; - padding: 13px 0 0; + padding: calc(var(--explorer-divider-gap) + var(--explorer-divider-width) + 1px) 0 0; border: 0; } .object-dataset-content > .object-dataset-details::before { diff --git a/site/shell/shell-layout.css b/site/shell/shell-layout.css index a17410320aa..d39d159398b 100644 --- a/site/shell/shell-layout.css +++ b/site/shell/shell-layout.css @@ -39,6 +39,9 @@ body[data-object-shell] { --explorer-panel-background: var(--explorer-surface-background); --explorer-divider: #000; --explorer-divider-width: 2px; + /* The space a card leaves above and below each of its dividers, to the nearest line of text or image: 14px measured on + both sides of every divider (2026-10-01), where the header's had 14 above and 22 or 30 below and a dataset's 12 and 12. */ + --explorer-divider-gap: 12px; --explorer-control-border: var(--shell-border); --explorer-control-text: var(--shell-text-secondary); } @@ -76,7 +79,7 @@ body[data-object-shell] { .object-card > .object-selected-panel:has(+ :is(.object-card-tabs, .object-tabs))::after { content: ""; display: block; - margin-top: 12px; + margin-top: var(--explorer-divider-gap); margin-inline: calc(-1 * var(--explorer-card-padding-inline-start)) calc(-1 * var(--explorer-card-padding-inline-end)); border-top: var(--explorer-divider-width) solid var(--explorer-divider); } From 40450d142e1c7de23da1d161f486517c7f635523 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 09:12:36 -0300 Subject: [PATCH 08/23] chore: re-pin the Sun's world context after merging main --- .../world-context/world-context-planner.ts | 13 ++++--------- src/objects/sun/inventory.json | 14 ++++++++++---- 2 files changed, 14 insertions(+), 13 deletions(-) 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 f169fb427c4..655cdc102ec 100644 --- a/packages/renderer/src/universe/world-context/world-context-planner.ts +++ b/packages/renderer/src/universe/world-context/world-context-planner.ts @@ -99,15 +99,13 @@ 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); - // An extended body (a galaxy, a cluster, a nebula) is a point here: its framing radius is no sphere that hides or resolves. 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), kind: 'classification' in body ? body.classification : undefined, extended: 'classification' in body && isExtendedClassification(body.classification), @@ -345,15 +343,12 @@ 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. + // 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); - // The focus, a featured star, a star outside the Milky Way and a black hole keep their dots; planets fade with their system. 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. + // 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)) * diff --git a/src/objects/sun/inventory.json b/src/objects/sun/inventory.json index 10ebc280ed7..f0c7e6148c5 100644 --- a/src/objects/sun/inventory.json +++ b/src/objects/sun/inventory.json @@ -211,6 +211,12 @@ "bytes": 1320, "sha256": "6ff26038d012dd2df122b0d606ac7678e77b4501c04d1bf2307e5475a0046b7d" }, + { + "location": "prepared", + "filename": "provenance.json", + "bytes": 89168, + "sha256": "9a7a704b2c3b11451e430871f24b5f6054d9f9b85fa92ecc2c13e42293acc6be" + }, { "location": "prepared", "filename": "runtime.json", @@ -4630,14 +4636,14 @@ { "location": "prepared", "filename": "world-context-summary.json", - "bytes": 862042, - "sha256": "1571c3c14b8fe30309289eb79aaa00745a96cbe15a764f660317f2964549c0aa" + "bytes": 865808, + "sha256": "328127e24e9f5e97f7945906c0809b07142b405c19c2d84f6d259e13c9401c39" }, { "location": "prepared", "filename": "world-context.json", - "bytes": 52984403, - "sha256": "9de84ea9b707e009833e844201e9b149d172b6a9c4c4d37a02146760e2deecf6" + "bytes": 53191775, + "sha256": "d1187d9725aa35c272a8765e541a86bbd158de0116c1675d68cc1ac68721cd5f" }, { "location": "prepared", From 1fe312eeced820dbcb53ed8317508feea5bc1908 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 09:43:08 -0300 Subject: [PATCH 09/23] refactor(registry): one entry shape with capabilities instead of three kinds --- .gitignore | 1 - .../site-assets/prepare-search-thumbnails.ts | 2 +- .../src/node/catalog-directory.test.ts | 12 +- .../objects/src/node/catalog-directory.ts | 31 +- .../src/node/prepared-registry.test.ts | 35 +- .../objects/src/node/prepared-registry.ts | 31 +- packages/objects/src/registry/index.ts | 6 +- .../objects/src/registry/object-catalog.ts | 40 +- .../objects/src/registry/object-schema.ts | 3 +- .../src/registry/overview-object.test.ts | 46 +- .../objects/src/registry/overview-object.ts | 61 +- .../src/registry/prepared-focus-object.ts | 90 +- site/build/prepare/prepare-catalog.mts | 38 +- site/build/prepare/prepare-spatial-context.ts | 5 +- site/build/prerender-focus-pages.mts | 3 +- site/built-pages.mts | 5 +- site/components/OverviewCard.astro | 21 +- site/layouts/ObjectLayout.astro | 3 +- site/navigation/navigation-scope.mts | 2 +- site/object-directory.mts | 5 +- site/objects.mts | 12 +- site/page-datasets.mts | 2 +- site/pages/[id].astro | 5 +- site/pages/sitemap.xml.ts | 3 +- site/scene/scene-router.mts | 8 +- site/search/prepared-catalogue-index.mts | 10 +- site/search/search-objects.mts | 5 +- site/seo-trail.mts | 3 +- site/shell/object-shell-client.mts | 5 +- site/test/galactic-focus.test.mts | 6 +- site/test/navigation-ontology.test.mts | 27 +- site/test/navigation-test-values.mts | 2 +- site/test/nebula-search.test.mts | 8 +- site/test/object-navigation.test.mts | 2 +- site/test/overview-context.test.mts | 9 +- src/objects/local-group/object.json | 2 - src/objects/sun/inventory.json | 1426 ++++++++--------- 37 files changed, 1000 insertions(+), 975 deletions(-) diff --git a/.gitignore b/.gitignore index 3ece45f7fee..0de63e64628 100644 --- a/.gitignore +++ b/.gitignore @@ -269,7 +269,6 @@ src/objects/*/source/**/*.bpc /site/prepared-facilities.json /site/prepared-catalogue.mjs /site/prepared-overview-objects.json -/site/prepared-level-bodies.json /site/prepared-dataset-volumes.json /site/prepared-stellar-extents.json /site/prepared-local-group-galaxies.json diff --git a/packages/bake/src/site-assets/prepare-search-thumbnails.ts b/packages/bake/src/site-assets/prepare-search-thumbnails.ts index 3646c94ff6e..2ca948a8157 100644 --- a/packages/bake/src/site-assets/prepare-search-thumbnails.ts +++ b/packages/bake/src/site-assets/prepare-search-thumbnails.ts @@ -82,7 +82,7 @@ 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 (entry.sceneHostId !== undefined) 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/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 b9d57d00e03..cc551f2f23e 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, isHostedDescriptor, overviewEntry } from '../registry/index.js'; +import { catalogEntry, defineObjects, isHostedDescriptor, 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'); @@ -41,30 +41,31 @@ export async function readCatalog(objectsDirectory: string, distance: (descripto 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 && !isHostedDescriptor(descriptor)) continue; + if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.catalog !== undefined && !isHostedDescriptor(descriptor) && 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 0c08e4f0505..a3afc82574b 100644 --- a/packages/objects/src/node/prepared-registry.test.ts +++ b/packages/objects/src/node/prepared-registry.test.ts @@ -5,6 +5,8 @@ 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'; +import { isHostedObject, isOverviewObject } from '../registry/index.js'; // Temporary checkouts the tests make, removed once the file's tests finish. const temporary: string[] = []; @@ -23,15 +25,16 @@ const hosted = (id: string, sceneHostId: string) => ({ descriptor: { ...descript distance: parseNavigationDistance(parsec), discovery: discovery(false), sceneHostId }); const focus = hosted('helix', 'sun'); -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'), sceneHostId: 'sun' }; 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?: Parameters[1]; module?: string; overviews?: unknown; levelBodies?: 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) { @@ -41,9 +44,8 @@ async function checkout(records: { scenes?: Parameters registry.requireSceneObject('helix'), /prepared focus/); + assert.throws(() => registry.requireSceneObject('helix'), /no scene of its own/); // A hosted package is a body of the world as a scene is, with its identity from its own descriptor. assert.deepEqual(registry.worldObjects.map(object => object.id), ['sun', 'mars', 'helix']); const helix = registry.objects.find(object => object.id === 'helix')!; - // A level that is a body too joins the world's objects; its registry entry stays its overview. - const placed = readPreparedObjects(await checkout({ levelBodies: [{ kind: 'prepared-focus', ...hosted('milky-way', 'sun') }] })); - assert.deepEqual(placed.worldObjects.map(object => object.id), ['sun', 'mars', 'helix', 'milky-way']); - assert.equal(placed.objects.find(object => object.id === 'milky-way')?.kind, 'overview'); - assert.partialDeepStrictEqual(helix, { kind: 'prepared-focus', classification: 'nebula', route: '/helix/', sceneHostId: 'sun', systemName: 'Solar System' }); - assert.throws(() => registry.requireSceneObject('milky-way'), /is an overview, not a scene owner/); + // A level with a catalogue entry is a body of the world too, and still one object. + const placedLevel = { descriptor: { ...hosted('local-group', 'sun').descriptor, type: 'galaxy-catalog', properties: { ...hosted('local-group', 'sun').descriptor.properties, + overview: { order: 2, zoom: level('x', 'x').properties.overview.zoom, holds: [{ classifications: ['galaxy'] }] } } }, + distance: parseNavigationDistance(parsec), discovery: discovery(true), sceneHostId: 'sun' }; + const placed = readPreparedObjects(await checkout({ overviews: [overview, placedLevel] })); + assert.deepEqual(placed.worldObjects.map(object => object.id), ['sun', 'mars', 'helix', 'local-group']); + assert.deepEqual(placed.objects.filter(isOverviewObject).map(object => object.id), ['milky-way', 'local-group']); + assert.equal(isHostedObject(helix), true); + assert.partialDeepStrictEqual(helix, { classification: 'nebula', route: '/helix/', sceneHostId: 'sun', systemName: 'Solar System' }); + assert.throws(() => registry.requireSceneObject('milky-way'), /no scene of its own/); assert.throws(() => registry.requireSceneObject('pluto'), /Unknown/); }); @@ -78,14 +84,13 @@ test('reads each checkout once per process', async () => { test('refuses a catalogue it cannot decode or a focus 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: [hosted('helix', 'mars'), hosted('m1', 'jupiter')] }, /not a registered scene: m1/], [{ 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: [{ ...overview, sceneHostId: 'jupiter' }] }, /Scene host is not a registered scene: milky-way/], [{ overviews: {} }, /overviews/], - [{ levelBodies: [{ kind: 'prepared-focus', ...hosted('m1', 'sun') }] }, /Level body is not a registered overview: m1/], ]; for (const [records, error] of cases) { const root = await checkout(records); diff --git a/packages/objects/src/node/prepared-registry.ts b/packages/objects/src/node/prepared-registry.ts index 6402283cb5a..67d054aa7ec 100644 --- a/packages/objects/src/node/prepared-registry.ts +++ b/packages/objects/src/node/prepared-registry.ts @@ -4,8 +4,8 @@ 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 { catalogueObject, defineObjects, isPlacedObject, isSceneObject } from '../registry/index.js'; +import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, PlacedFields } 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 package the world's host draws, in registry order. The overviews are apart because every @@ -13,18 +13,16 @@ import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, export const PREPARED_CATALOGUE = Object.freeze({ entries: 'site/prepared-catalogue.mjs', overviews: 'site/prepared-overview-objects.json', - /** The levels that are also bodies of the world (the Local Group, marked among the clusters): each one's descriptor with - * its distance and discovery, as a hosted package's row. Its registry entry stays its overview. */ - levelBodies: 'site/prepared-level-bodies.json', }); +/** One row of the prepared catalogue: a package's descriptor, with its distance and discovery when it is placed and the scene + * that draws it when it has none of its own. Every object's row has this shape (`catalogueObject` reads it). */ +export interface PreparedCatalogueRow { descriptor: unknown; distance?: NavigationDistance; discovery?: ObjectDiscovery; sceneHostId?: string } + /** The catalogue module `prepare:catalog` writes at `PREPARED_CATALOGUE.entries`. */ -export function preparedCatalogueModule(scenes: readonly { descriptor: unknown; distance: NavigationDistance; discovery: ObjectDiscovery }[], - hosted: readonly { descriptor: unknown; distance: NavigationDistance; discovery: ObjectDiscovery; sceneHostId: string }[]) { +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 })), - ...hosted.map(({ descriptor, distance, discovery, sceneHostId }) => ({ kind: 'prepared-focus', descriptor, distance, discovery, sceneHostId }))]; 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`; } @@ -40,7 +38,7 @@ export interface PreparedObjectRegistry { /** The scene objects, as `SCENE_OBJECTS` lists them. */ readonly sceneObjects: readonly PreparedSceneObject[]; /** Every placed object the world draws: the scenes and the packages the world's host draws (galaxies, nebulae, clusters). */ - readonly worldObjects: readonly (PreparedSceneObject | PreparedFocusObject)[]; + readonly worldObjects: readonly (PreparedNavigableObject & PlacedFields)[]; requireSceneObject(id: string): PreparedSceneObject; } @@ -71,18 +69,15 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { 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}`); + for (const object of objects) if (!isSceneObject(object) && !sceneObjects.some(host => host.id === object.sceneHostId)) { + throw new TypeError(`Scene host is not a registered scene: ${object.id}`); } - const levelRows: unknown = load(resolve(checkout, PREPARED_CATALOGUE.levelBodies)); - if (!Array.isArray(levelRows)) throw new TypeError(`Invalid prepared level bodies: ${PREPARED_CATALOGUE.levelBodies}.`); - const levelBodies = levelRows.map(row => catalogueObject(row, refuse)).filter((body): body is PreparedFocusObject => body.kind === 'prepared-focus'); - for (const body of levelBodies) if (objects.find(object => object.id === body.id)?.kind !== 'overview') throw new TypeError(`Level body is not a registered overview: ${body.id}`); - const worldObjects = Object.freeze([...objects.filter((object): object is PreparedSceneObject | PreparedFocusObject => object.kind !== 'overview'), ...levelBodies]); + // Every object with a place in the world, whatever else it is. + const worldObjects = Object.freeze(objects.filter(isPlacedObject)); return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects, 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.`); + if (!isSceneObject(object)) throw new TypeError(`Object ${id} has no scene of its own.`); return object; } }); } diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index 7e9876795f9..5e43db48cf2 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -16,9 +16,9 @@ export { discoveryDescription, discoveryVisibility, isDiscoveryAnchor, offTheMap export type { ObjectDiscovery } from './object-discovery.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, destinationSearchNames, HOSTED_OBJECT_TYPES, hostedObject, isHostedDescriptor, 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, HOSTED_OBJECT_TYPES, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, isSceneObject } from './prepared-focus-object.js'; +export type { NavigableObject, PlacedFields, PreparedFocusObject } from './prepared-focus-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/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index 42a7c21f339..3d6803a254e 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -6,9 +6,9 @@ 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 { hostedObject } from './prepared-focus-object.js'; +import { destinationSearchNames, isHostedDescriptor } from './prepared-focus-object.js'; import type { NavigableObject } from './prepared-focus-object.js'; -import { defineOverview } from './overview-object.js'; +import { overviewLevel } 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). */ @@ -75,21 +75,27 @@ 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 when it is placed and the scene that draws it when it has none of + * its own. What the object is comes from the descriptor alone: a catalogue block places it, `properties.overview` makes it a + * level, and a scene type gives it a scene, which `loadScene` binds 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 === 'overview') return defineOverview(value); - if ((value.kind !== 'scene' && value.kind !== 'prepared-focus') || !isRecord(value.descriptor)) throw new TypeError('Invalid catalogue entry.'); - // A package the world's host draws: the same descriptor as a scene's, read into an entry with no scene of its own. - if (value.kind === 'prepared-focus') { - if (typeof value.sceneHostId !== 'string') throw new TypeError(`Invalid catalogue entry: ${String(value.descriptor.id)} names no scene host.`); - return hostedObject(catalogEntry(value.descriptor, async () => { throw new Error('A hosted object has no scene of its own.'); }, - parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)), value.sceneHostId); - } - 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, properties = value.descriptor.properties, id = value.descriptor.id, level = overviewLevel(descriptor); + const placed = properties.catalog !== undefined, scene = placed && level === null && !isHostedDescriptor(descriptor); + if (!placed && level === null) throw new TypeError(`Invalid catalogue entry: ${id} has neither a catalogue entry nor an overview.`); + if (!scene && (typeof value.sceneHostId !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(value.sceneHostId))) throw new TypeError(`Invalid catalogue entry: ${id} names no scene host.`); + const entry = placed ? catalogEntry(descriptor, scene ? loadScene(descriptor) : async () => { throw new Error(`${id} has no scene of its own.`); }, + parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)) : null; + if (entry && scene) { const { order: _order, context: _context, ...object } = entry; return object; } + const { name: levelName, description: levelDescription, ...ladder } = level ?? {}; + const name = entry?.name ?? levelName, description = entry?.description ?? levelDescription; + if (name === undefined || description === undefined) throw new TypeError(`Invalid catalogue entry: ${id} has no name or description.`); + return Object.freeze({ id, name, description, route: `/${id}/`, sceneHostId: value.sceneHostId as string, + ...(entry ? { classification: entry.classification, ...(entry.classificationLabel === undefined ? {} : { classificationLabel: entry.classificationLabel }), + systemName: entry.systemName, color: entry.color, distance: entry.distance, worldFrame: entry.worldFrame, discovery: entry.discovery, + searchNames: Object.freeze(destinationSearchNames([id, entry.name, ...entry.aliases])) } : {}), + ...ladder }); } diff --git a/packages/objects/src/registry/object-schema.ts b/packages/objects/src/registry/object-schema.ts index 6bbbabc15e0..7f038383c31 100644 --- a/packages/objects/src/registry/object-schema.ts +++ b/packages/objects/src/registry/object-schema.ts @@ -35,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", @@ -79,7 +79,6 @@ export function defineObject(input: ObjectDefinitionInput = {}) => ({ 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..aa39aac9fa0 100644 --- a/packages/objects/src/registry/overview-object.ts +++ b/packages/objects/src/registry/overview-object.ts @@ -1,4 +1,5 @@ import { isRecord } from '@cssearth/core'; +import type { ObjectClassification, ObjectWorldFrame } from './object-schema.js'; /** * A distance on the zoom ladder, measured from the star the zoom is centred on: a fixed distance, a point of the world @@ -37,7 +38,6 @@ export interface OverviewHolding { readonly classifications: readonly string[]; * with a volume, its origin. */ export interface OverviewObject { - readonly kind: 'overview'; readonly id: string; readonly name: string; /** The card's introduction and the page's description. */ @@ -49,9 +49,12 @@ 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]; + /** Its world frame, for a level with a catalogue entry (the Local Group): the world context places it. */ + readonly worldFrame?: ObjectWorldFrame; readonly route: string; readonly sceneHostId: string; } @@ -100,42 +103,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 index 713e508461c..4087c48a87f 100644 --- a/packages/objects/src/registry/prepared-focus-object.ts +++ b/packages/objects/src/registry/prepared-focus-object.ts @@ -1,25 +1,54 @@ import { isRecord } from '@cssearth/core'; -import { parseNavigationDistance } from './navigation-distance.js'; import type { NavigationDistance } from './navigation-distance.js'; -import { parseWorldFrame } from './object-schema.js'; -import type { ObjectEntry, ObjectWorldFrame } from './object-schema.js'; -import { parseObjectDiscovery } from './object-discovery.js'; +import type { ObjectClassification, ObjectDefinitionInput, ObjectEntry, ObjectWorldFrame } from './object-schema.js'; import type { ObjectDiscovery } from './object-discovery.js'; import { normalizeDestinationQuery } from './destination-search.js'; -import type { OverviewObject } from './overview-object.js'; +import type { OverviewHolding, OverviewObject, OverviewZoom } from './overview-object.js'; +/** + * An object of the one registry. Every object has an identity and a page; what else it has is what its package declares: + * a place in the world (its catalogue block and world frame), a scene of its own (`loadScene`), a level of the zoom ladder + * (`zoom`). A planet is placed and has a scene; a galaxy package is placed and is drawn by the world's host; the Local Group + * is placed and is a level; the observable universe is a level only. There is no kind: code asks for the capability it needs. + */ +export interface NavigableObject { + readonly id: string; + readonly name: string; + readonly description: string; + readonly route: string; + /** Placed: as the package's catalogue block and world frame declare them. */ + readonly classification?: ObjectClassification; + readonly classificationLabel?: string; + readonly systemName?: string; + readonly color?: string; + readonly distance?: NavigationDistance; + readonly worldFrame?: ObjectWorldFrame; + readonly discovery?: Readonly; + readonly aliases?: readonly string[]; + /** The names an object without a scene is found by. */ + readonly searchNames?: readonly string[]; + /** A scene of its own; without one the object is drawn by `sceneHostId`'s scene. */ + readonly loadScene?: ObjectDefinitionInput['loadScene']; + readonly sceneHostId?: string; + /** A level of the zoom ladder (overview-object.ts). */ + readonly order?: number; + readonly zoom?: OverviewZoom; + readonly holds?: readonly OverviewHolding[]; + readonly packages?: readonly string[]; + readonly originM?: readonly [number, number, number]; +} + +/** A placed object the world's host draws: a galaxy, a cluster of galaxies, a nebula or a globular cluster with a package. */ 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 classification: ObjectClassification; + readonly classificationLabel?: string; readonly systemName: string; readonly route: string; readonly sceneHostId: string; readonly distance: NavigationDistance; - /** As the package's catalogue block and world frame declare them: the world context places and names it like any body. */ readonly color: string; readonly description: string; readonly worldFrame: ObjectWorldFrame; @@ -28,39 +57,20 @@ export interface PreparedFocusObject { /** The package types the world's host draws instead of mounting a scene of their own: image layers, a volume, catalogue points. */ export const HOSTED_OBJECT_TYPES: readonly string[] = Object.freeze(['image-layer-bank', 'volume-dataset-bank', 'catalogue-point-bank']); export const isHostedDescriptor = (descriptor: unknown): boolean => isRecord(descriptor) && typeof descriptor.type === 'string' && HOSTED_OBJECT_TYPES.includes(descriptor.type); -/** 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'; -/** The names a hosted object is found by: its id, name and aliases, normalised, with and without spaces. */ +/** The object mounts a scene of its own. */ +export const isSceneObject = (object: NavigableObject): object is ObjectEntry => typeof object.loadScene === 'function'; +/** The object is a level of the zoom ladder. */ +export const isOverviewObject = (object: NavigableObject | NavigableObject): object is OverviewObject => (object as NavigableObject).zoom !== undefined; +/** The object has a place in the world: the world context draws, names and selects it. */ +export const isPlacedObject = (object: NavigableObject): object is NavigableObject & PlacedFields => object.worldFrame !== undefined; +export type PlacedFields = Pick; +/** A placed object with neither a scene nor a level: its page is the host's scene framed on it. */ +export const isHostedObject = (object: NavigableObject): object is PreparedFocusObject => + object.worldFrame !== undefined && typeof object.loadScene !== 'function' && object.zoom === undefined; + +/** The names an object without a scene 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(' ', '')]; }))]; - -/** A package the world's host draws, from its own entry (its descriptor's catalogue block, world frame and discovery). It - * reuses the host scene and camera; it has no scene loader. */ -export function hostedObject(entry: Pick & { readonly aliases?: readonly string[] }, - sceneHostId: string): PreparedFocusObject { - return definePreparedFocus({ kind: 'prepared-focus', id: entry.id, focusId: entry.id, name: entry.name, - searchNames: destinationSearchNames([entry.id, entry.name, ...entry.aliases ?? []]), classification: entry.classification, - systemName: entry.systemName, route: `/${entry.id}/`, sceneHostId, distance: entry.distance, color: entry.color, - description: entry.description, worldFrame: entry.worldFrame, discovery: entry.discovery }); -} - -export function definePreparedFocus(input: unknown): PreparedFocusObject { - if (!isRecord(input) || Object.keys(input).some(key => !['kind', 'id', 'focusId', 'name', 'searchNames', 'classification', 'systemName', 'route', 'sceneHostId', 'distance', 'color', 'description', 'worldFrame', 'discovery'].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 || - typeof input.color !== 'string' || !/^#[0-9a-f]{6}$/u.test(input.color) || typeof input.description !== 'string' || !input.description || !isRecord(input.worldFrame) || - (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), color: input.color, - description: input.description, worldFrame: parseWorldFrame(input.worldFrame), discovery: parseObjectDiscovery(input.discovery) }); -} diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index 0b6da39b081..43b1bdc0fb7 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -140,15 +140,14 @@ export async function prepareCatalog({ projectRoot = root } = {}) { // Every descriptor once, shared by each read below. const descriptors = await readObjectDescriptors(resolve(projectRoot, 'src/objects')); const catalogued = await readCatalog(resolve(projectRoot, 'src/objects'), prepareSceneDistance, descriptors); - // A level that is also a body of the world (the Local Group among the clusters) keeps its overview as its registry entry. - const authorsOverview = (id: string) => { const descriptor = descriptors.get(id); return isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.overview !== undefined; }; - const entries = catalogued.filter(entry => !authorsOverview(entry.id)), levels = catalogued.filter(entry => authorsOverview(entry.id)); + // 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)); 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.'); - // The spatial catalogues are data: they name no destination. Every page is a package's own entry: a scene, or a package - // the world's host draws (image layers, a volume, catalogue points), which the levels above the star systems are too. - const overviews = await readOverviews(resolve(projectRoot, 'src/objects'), host.id, descriptors); - defineObjects<{ id: string; route: string }>([...entries, ...overviews]); + if (!host) throw new TypeError('Objects without a scene of their own need a shared world host.'); + // 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)); })); @@ -160,14 +159,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(...(() => { - const rows = entries.map(({ id, distance }, index) => ({ descriptor: descriptors.get(id), distance, discovery: discoveries[index]! })); - return [rows.filter(row => !isHostedDescriptor(row.descriptor)), rows.filter(row => isHostedDescriptor(row.descriptor)).map(row => ({ ...row, sceneHostId: host.id }))] as const; - })())); - await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.overviews), JSON.stringify(overviews) + '\n'); - await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.levelBodies), JSON.stringify(levels.map(({ id, distance }) => ({ kind: 'prepared-focus', - descriptor: descriptors.get(id), distance, discovery: { featured: true, imagery: false, illustration: false }, sceneHostId: host.id }))) + '\n'); + // One row shape for every object: its descriptor, its distance and discovery when placed, its host when it has no scene. + await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.entries), preparedCatalogueModule(entries.map(({ id, distance }, index) => { + const descriptor = descriptors.get(id); + return { descriptor, distance, discovery: discoveries[index]!, ...(isHostedDescriptor(descriptor) ? { sceneHostId: host.id } : {}) }; + }))); + // 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 } } : {}), sceneHostId: host.id }; + })) + '\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-spatial-context.ts b/site/build/prepare/prepare-spatial-context.ts index 125419e385f..d06c3fa67e2 100644 --- a/site/build/prepare/prepare-spatial-context.ts +++ b/site/build/prepare/prepare-spatial-context.ts @@ -4,7 +4,6 @@ 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 { isPlacedClassification, parseObjectDescriptor } from '@cssearth/objects'; -import { isRecord } from '@cssearth/core'; import { packPreparedBinary, readCatalog, readPreparedObjects } from '@cssearth/objects/node'; import { worldOrbitBankRegions } from '@cssearth/renderer'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; @@ -88,9 +87,7 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp 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' || entry.kind === 'prepared-focus') && isRecord(entry.descriptor) && typeof entry.descriptor.id === 'string' ? [[entry.descriptor.id, entry.discovery]] : [])); - for (const object of registry) if (!Object.hasOwn(discoveries, object.id)) discoveries[object.id] = object.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; diff --git a/site/build/prerender-focus-pages.mts b/site/build/prerender-focus-pages.mts index 2db478fb546..67509482808 100644 --- a/site/build/prerender-focus-pages.mts +++ b/site/build/prerender-focus-pages.mts @@ -1,3 +1,4 @@ +import { isSceneObject } from '@cssearth/objects'; // Entry script: node site/build/prerender-focus-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 @@ -33,7 +34,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).objects.filter(object => !isSceneObject(object)).map(object => object.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..469f1fcca67 100644 --- a/site/built-pages.mts +++ b/site/built-pages.mts @@ -2,7 +2,8 @@ // 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 } +/** An object's page: its own scene's, or the scene of `sceneHostId` 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 +48,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/OverviewCard.astro b/site/components/OverviewCard.astro index 34299be37cf..ff91b9ed4a7 100644 --- a/site/components/OverviewCard.astro +++ b/site/components/OverviewCard.astro @@ -1,4 +1,5 @@ --- +import { isHostedObject, isOverviewObject, isPlacedObject } from '@cssearth/objects'; import type { OverviewObject } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import type { Props as ObjectProps } from '../object-shell-types'; @@ -14,7 +15,6 @@ 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 { pageDatasetList } from '../page-datasets-content.mts'; import overviewFacts from '../source/overview-facts.json'; @@ -25,7 +25,6 @@ import overviewFacts from '../source/overview-facts.json'; 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' }; @@ -33,19 +32,19 @@ const distance = (pc: number) => { 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') { + // A package the world's host draws is where its own world frame puts it. + if (isHostedObject(object)) { 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 }]; + return [{ id: object.id, name: object.name, positionM: object.worldFrame.originM, distancePc: object.distance.meters / PARSEC_M, holdsStars: false }]; } - if (object.kind === 'overview') { + if (isOverviewObject(object)) { 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, + const originM = object.worldFrame?.originM ?? object.originM; + if (!originM) throw new Error(`${overview.id} lists ${object.id}, whose package has neither a world frame nor a volume origin to place it.`); + return [{ id: object.id, name: object.name, positionM: originM, distancePc: Math.hypot(...originM) / PARSEC_M, holdsStars: object.holds.some(group => group.classifications.includes('star')) }]; } - if (!classifications.includes(object.classification) || !object.worldFrame) return []; + if (!isPlacedObject(object) || !classifications.includes(object.classification)) return []; return [{ 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); @@ -65,7 +64,7 @@ const sceneMembers = SCENE_OBJECTS.filter(object => classifications.includes(obj 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 row = object && isOverviewObject(object) ? overviewResult(object) : 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'; diff --git a/site/layouts/ObjectLayout.astro b/site/layouts/ObjectLayout.astro index d007dc9ae18..150a24c5388 100644 --- a/site/layouts/ObjectLayout.astro +++ b/site/layouts/ObjectLayout.astro @@ -1,4 +1,5 @@ --- +import { isHostedObject } from '@cssearth/objects'; import { informationTabStyles } from '../server/information-tab-styles.mts'; import ObjectSwatchStyles from "../components/ObjectSwatchStyles.astro"; import { webAnalyticsBootstrap } from "../web-analytics.mts"; @@ -60,7 +61,7 @@ const startupCover = startup?.arrival?.billboard && scene?.coverPlan && pageId = const assetPreconnect = startupImage && /^https?:\/\//.test(startupImage) ? new URL(startupImage).origin : undefined; const seoOptions = { socialImages: availableSocialImages(SCENE_OBJECTS), defaultSocialImageId: DEFAULT_SOCIAL_IMAGE_ID }; const pageRecord = requireObject(pageId); -const pageSeo = pageRecord.kind === 'prepared-focus' ? focusSeo(pageRecord, seoOptions) : objectSeo(pageRecord, seoOptions); +const pageSeo = isHostedObject(pageRecord) ? focusSeo(pageRecord, seoOptions) : objectSeo(pageRecord, seoOptions); const seo = home ? homeSeo(pageSeo) : pageSeo; const structuredData = JSON.stringify(home ? websiteJsonLd() : breadcrumbJsonLd(pageTrail(pageRecord))).replace(/; -export const isOverviewPage = (id: string | null | undefined): id is OverviewPageId => typeof id === 'string' && knownObject(id)?.kind === 'overview'; +export const isOverviewPage = (id: string | null | undefined): id is OverviewPageId => typeof id === 'string' && knownObject(id)?.zoom !== undefined; /** The page a URL names when it is something the mounted scene `sceneId` draws, not the scene itself; null on the * scene's own page. The URL is the selection from the moment it is named, before any bank has loaded. */ diff --git a/site/object-directory.mts b/site/object-directory.mts index 48afab4b636..f19b9908349 100644 --- a/site/object-directory.mts +++ b/site/object-directory.mts @@ -1,4 +1,4 @@ -import { catalogueObject, defineOverview, isSceneObject } from '@cssearth/objects'; +import { catalogueObject, isOverviewObject, isSceneObject } from '@cssearth/objects'; import type { OverviewObject } from '@cssearth/objects'; import type { NavigableObject, ObjectEntry } from './objects.mts'; import overviews from './prepared-overview-objects.json' with { type: 'json' }; @@ -20,7 +20,8 @@ function add(object: NavigableObject) { } /** The overviews, from the nearest level of the zoom ladder out: every page knows them (see above). The build's registry * holds the same entries (objects.mts OVERVIEWS). */ -export const KNOWN_OVERVIEWS: readonly OverviewObject[] = Object.freeze(overviews.map(defineOverview).sort((a, b) => a.order - b.order)); +export const KNOWN_OVERVIEWS: readonly OverviewObject[] = Object.freeze(overviews.map(row => catalogueObject(row, () => async () => { throw new Error('A level has no scene of its own.'); })) + .filter(isOverviewObject).sort((a, b) => a.order - b.order)); for (const overview of KNOWN_OVERVIEWS) add(overview); /** Whether a loaded object owns a scene. */ export const isLoadedScene = isSceneObject; diff --git a/site/objects.mts b/site/objects.mts index 01e1e4dbabf..24339a7e30d 100644 --- a/site/objects.mts +++ b/site/objects.mts @@ -1,5 +1,5 @@ -import { defineObjects, isSceneObject } from '@cssearth/objects'; -import type { CatalogEntry as RegistryCatalogEntry, NavigableObject as RegistryNavigableObject, ObjectEntry as RegistryObjectEntry, OverviewObject } from '@cssearth/objects'; +import { defineObjects, isOverviewObject, isSceneObject } from '@cssearth/objects'; +import type { CatalogEntry as RegistryCatalogEntry, NavigableObject as RegistryNavigableObject, ObjectEntry as RegistryObjectEntry } from '@cssearth/objects'; import { CATALOGUE_ENTRIES } from './prepared-catalogue.mjs'; import overviews from './prepared-overview-objects.json' with { type: 'json' }; import { objectFromEntry } from './object-directory.mts'; @@ -16,12 +16,12 @@ export const OBJECTS = defineObjects([...CATALOGUE_ENTRIES, ... /** A capability projection of OBJECTS, never an independently maintained registry. */ export const SCENE_OBJECTS = Object.freeze(OBJECTS.filter(isSceneObject)); -for (const object of OBJECTS) if (object.kind !== 'scene' && !SCENE_OBJECTS.some(host => host.id === object.sceneHostId)) { - throw new TypeError(`${object.kind === 'overview' ? 'Overview' : 'Prepared focus'} host is not a registered scene: ${object.id}`); +for (const object of OBJECTS) if (!isSceneObject(object) && !SCENE_OBJECTS.some(host => host.id === object.sceneHostId)) { + throw new TypeError(`Scene host is not a registered scene: ${object.id}`); } /** The overviews, from the nearest level of the zoom ladder out: a projection of OBJECTS like SCENE_OBJECTS. */ -export const OVERVIEWS = Object.freeze(OBJECTS.filter((object): object is OverviewObject => object.kind === 'overview') +export const OVERVIEWS = Object.freeze(OBJECTS.filter(isOverviewObject) .sort((a, b) => a.order - b.order)); export function requireObject(id: string) { @@ -32,6 +32,6 @@ export function requireObject(id: string) { export function requireSceneObject(id: string) { const object = requireObject(id); - if (!isSceneObject(object)) throw new TypeError(`Object ${id} is ${object.kind === 'overview' ? 'an overview' : 'a prepared focus'}, not a scene owner.`); + if (!isSceneObject(object)) throw new TypeError(`Object ${id} has no scene of its own.`); return object; } diff --git a/site/page-datasets.mts b/site/page-datasets.mts index 92da768a6c0..a5cb221c64c 100644 --- a/site/page-datasets.mts +++ b/site/page-datasets.mts @@ -31,7 +31,7 @@ export function selectedPageDataset(url: string | URL, datasets: Pick('button[name="dataset"]')]; diff --git a/site/pages/[id].astro b/site/pages/[id].astro index f4a00c2a4dd..7ff0818ea9a 100644 --- a/site/pages/[id].astro +++ b/site/pages/[id].astro @@ -1,4 +1,5 @@ --- +import { isSceneObject } from '@cssearth/objects'; import ObjectPage from '../components/ObjectPage.astro'; import { OBJECTS } from '../objects.mts'; import { WORLD_HOST_ID } from '../navigation/navigation-scope.mts'; @@ -6,10 +7,10 @@ import { builtObjectPages } from '../built-pages.mts'; export function getStaticPaths() { // Every object has its page, `//`. A scene's page mounts it; the page of anything the world draws around a scene // (a catalogue subject, an overview) mounts its host, the world's, with it selected: the path names it. - for (const object of OBJECTS) if (object.kind !== 'scene' && object.sceneHostId !== WORLD_HOST_ID) { + for (const object of OBJECTS) if (!isSceneObject(object) && object.sceneHostId !== WORLD_HOST_ID) { throw new TypeError(`${object.id} is hosted by ${object.sceneHostId}, not the world's host ${WORLD_HOST_ID}.`); } - return builtObjectPages(OBJECTS.map(object => ({ params: { id: object.id }, props: { sceneId: object.kind === 'scene' ? object.id : object.sceneHostId } })), OBJECTS); + return builtObjectPages(OBJECTS.map(object => ({ params: { id: object.id }, props: { sceneId: isSceneObject(object) ? object.id : object.sceneHostId! } })), OBJECTS); } --- diff --git a/site/pages/sitemap.xml.ts b/site/pages/sitemap.xml.ts index f280d26ecf7..30b4be48047 100644 --- a/site/pages/sitemap.xml.ts +++ b/site/pages/sitemap.xml.ts @@ -1,9 +1,10 @@ +import { isHostedObject } from "@cssearth/objects"; import { OBJECTS } from "../objects.mts"; import { focusSeo, objectSeo } from "../seo.mts"; /** Every page, `//`: each scene, each catalogue subject and each overview. */ export function GET() { - const pages = OBJECTS.map(object => object.kind === "prepared-focus" ? focusSeo(object) : objectSeo(object)); + const pages = OBJECTS.map(object => isHostedObject(object) ? focusSeo(object) : objectSeo(object)); const urls = pages.map((page) => ` ${page.canonical}`).join("\n"); return new Response( `\n` + diff --git a/site/scene/scene-router.mts b/site/scene/scene-router.mts index c883338ebb0..815b73596d6 100644 --- a/site/scene/scene-router.mts +++ b/site/scene/scene-router.mts @@ -10,6 +10,10 @@ import { focusExistingScene, prepareSceneReplacement } from './scene-transition. import type { BrowserWindow, SceneFactory } from '../browser/browser-types.mts'; import { errorMessage } from '../browser/browser-types.mts'; import { isRecord } from '@cssearth/core'; +import { isHostedObject } from '@cssearth/objects'; +import type { NavigableObject } from '../objects.mts'; +/** A placed object the world's host draws: its page is the host's scene framed on it. */ +const hosted = (object: NavigableObject | null | undefined) => object != null && isHostedObject(object); import type { ObjectEntry } from '../objects.mts'; import type { ObjectDescriptor } from '@cssearth/objects'; import type { WorldCameraPose } from '@cssearth/renderer/navigation/world-camera.ts'; @@ -338,7 +342,7 @@ export function createSceneRouter({ // A link flies in place to any body the world draws; one whose entry has loaded must also share this frame. unbindLinks = bindNavigationLinks({ documentTarget, windowTarget, navigable: id => navigable(id), navigate, onError: report }); } - navigable = id => isOverviewPage(id) || registry.knownObject(id)?.kind === 'prepared-focus' || worldIds.has(id) && (!registry.knownObject(id) || navigation.supports(objectId, id)); + navigable = id => isOverviewPage(id) || hosted(registry.knownObject(id)) || worldIds.has(id) && (!registry.knownObject(id) || navigation.supports(objectId, id)); if (destroyed) navigation.destroy(); return context = { registry, objects, navigation, selection, activation }; }); @@ -400,7 +404,7 @@ export function createSceneRouter({ startFlightRequest(preparedObjectUrl(id, 'prepared/object.json')); if (intent.kind !== 'feature') void registry.loadSystemView(id).catch(() => {}); } - const focus = !isOverviewPage(id) && (registry.knownObject(id) ?? await registry.loadObject(id).catch(() => null))?.kind === 'prepared-focus'; + const focus = !isOverviewPage(id) && hosted(registry.knownObject(id) ?? await registry.loadObject(id).catch(() => null)); if (destroyed) return false; if (isOverviewPage(id)) id = WORLD_HOST_ID; else if (focus) id = scenes.current ? objectId : WORLD_HOST_ID; diff --git a/site/search/prepared-catalogue-index.mts b/site/search/prepared-catalogue-index.mts index 5c5b9ae2d9e..2b17fb1e710 100644 --- a/site/search/prepared-catalogue-index.mts +++ b/site/search/prepared-catalogue-index.mts @@ -18,7 +18,7 @@ export function preparedCatalogueIndex(): CatalogueIndex { const source = sourceDocumentation(object.id, object.name); const value = String(Number(object.distance.value.toFixed(3))); return Object.freeze({ - kind: object.kind, + kind: 'scene' as const, id: object.id, name: object.name, searchNames: Object.freeze([]), @@ -40,10 +40,10 @@ export function preparedCatalogueIndex(): CatalogueIndex { const value = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 }).format(object.distance.value); const detail = `${value} pc${object.distance.quantity === 'comoving' ? ' (comoving)' : ''}`; return Object.freeze({ - kind: object.kind, - id: object.focusId, + kind: 'prepared-focus' as const, + id: object.id, name: object.name, - searchNames: Object.freeze(object.searchNames.map(name => name.toLocaleLowerCase('en'))), + searchNames: Object.freeze((object.searchNames ?? []).map(name => name.toLocaleLowerCase('en'))), classification: object.classification, classificationName: objectClassificationLabel(object.classification).toLocaleLowerCase('en'), systemName: object.systemName.toLocaleLowerCase('en'), @@ -51,7 +51,7 @@ export function preparedCatalogueIndex(): CatalogueIndex { illustration: false, distanceMeters: object.distance.meters, detail: Object.freeze({ text: detail, title, ariaLabel: `${object.distance.value} pc. ${title}` }), - source: Object.freeze({ subject: `focus:${object.focusId}`, document: source.href, label: source.label }), + source: Object.freeze({ subject: `focus:${object.id}`, document: source.href, label: source.label }), marker: Object.freeze({ kind: 'focus' as const, thumbnail: sidebarThumbnail(object.id)?.url2x ?? null }), }); }), ...systemEntries()]), diff --git a/site/search/search-objects.mts b/site/search/search-objects.mts index 4211af48034..03b66ed07c8 100644 --- a/site/search/search-objects.mts +++ b/site/search/search-objects.mts @@ -1,3 +1,4 @@ +import { isOverviewObject, isPlacedObject } from '@cssearth/objects'; import { OBJECTS } from "../objects.mts"; // Pages label classifications without loading the registry. @@ -14,7 +15,7 @@ export function objectNavigation object.kind !== 'overview')); +// A level of the zoom ladder is listed in its own rows (CatalogueOverviewRows.astro), placed or not. +const navigation = objectNavigation(OBJECTS.filter(isPlacedObject).filter(object => !isOverviewObject(object))); export const SEARCH_OBJECTS = navigation.search; export const PLANET_NAVIGATION_OBJECTS = navigation.planets; diff --git a/site/seo-trail.mts b/site/seo-trail.mts index 4deb02dd8db..81e31285b6c 100644 --- a/site/seo-trail.mts +++ b/site/seo-trail.mts @@ -1,3 +1,4 @@ +import { isSceneObject } from '@cssearth/objects'; import { OBJECTS } from './objects.mts'; import { APPLICATION_WORLD_CONTEXT as context } from './world-context-plan.mts'; @@ -7,7 +8,7 @@ const parents = new Map([ ...context.bodies.flatMap(body => body.orbit ? [[body.id, body.orbit.centerBodyId] as const] : body.boundTo ? [[body.id, body.boundTo.hostId] as const] : []), ]); -const pages = new Map(OBJECTS.filter(object => object.kind === 'scene').map(object => [object.id, object])); +const pages = new Map(OBJECTS.filter(isSceneObject).map(object => [object.id, object])); /** The pages from the root of a body's orbit chain down to the page itself: Sun, Mars, Phobos. Centres without a page * (a barycentre) are passed through; a page outside every chain is its own one-step trail. */ diff --git a/site/shell/object-shell-client.mts b/site/shell/object-shell-client.mts index ac260cd818b..a4fb60fbea6 100644 --- a/site/shell/object-shell-client.mts +++ b/site/shell/object-shell-client.mts @@ -1,3 +1,4 @@ +import { isSceneObject } from '@cssearth/objects'; import { bindTabPanels } from '../tab-panels.mts'; import { sectionElements, sectionPlaceholder, showSection } from '@cssearth/renderer'; import { createObjectBrowserController } from '../object-browser.mts'; @@ -127,7 +128,7 @@ export function mountObjectShell({ const drawnPage = (selected: typeof subject | null) => { if (selected?.kind === 'focus') return selected.record ? { id: selected.record.id, seo: focusSeo(selected.record) } : null; const overview = selected?.kind === 'overview' ? knownObject(overviewPage(objectId, selected.overview.scope) ?? '') : undefined; - return overview?.kind === 'overview' ? { id: overview.id, seo: objectSeo(overview) } : null; + return overview?.zoom !== undefined ? { id: overview.id, seo: objectSeo(overview) } : null; }; const leftPage = presentedSubject?.kind === 'focus' || drawnPage(presentedSubject) !== null; presentedSubject = subject; @@ -138,7 +139,7 @@ export function mountObjectShell({ // A focus whose record is still loading is already this page's subject: its head waits for the record. else if (leftPage && !focus) { const scene = knownObject(subject.kind === 'object' ? subject.objectId : subject.kind === 'satellite-system' ? subject.hostId : objectId); - if (scene?.kind === 'scene') presentPage(scene.route, objectSeo(scene)); + if (scene && isSceneObject(scene)) presentPage(scene.route, objectSeo(scene)); } else pendingPage = null; focusCard.set(focus?.record ?? null, focus?.sources ?? [], focus?.presentation ?? null); if (focus) loadFocusBanks(); diff --git a/site/test/galactic-focus.test.mts b/site/test/galactic-focus.test.mts index dae6b1c890f..704da6c0650 100644 --- a/site/test/galactic-focus.test.mts +++ b/site/test/galactic-focus.test.mts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { catalogueObject } from '@cssearth/objects'; +import { catalogueObject, isHostedObject } from '@cssearth/objects'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { searchObjects } from '../search/object-search.mts'; @@ -15,9 +15,9 @@ test('a globular cluster package is an entry of the registry from its own descri // A hosted package'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({ kind: 'prepared-focus', descriptor, distance, discovery: { featured: false, imagery: true, illustration: false }, sceneHostId: 'sun' }, + const focus = catalogueObject({ descriptor, distance, discovery: { featured: false, imagery: true, illustration: false }, sceneHostId: 'sun' }, () => async () => { throw new Error('unused'); }); - assert.ok(focus.kind === 'prepared-focus'); + assert.ok(isHostedObject(focus)); assert.equal(focus.classification, 'globular-cluster'); assert.equal(focus.systemName, 'Milky Way'); assert.equal(focus.route, '/test-cluster/'); diff --git a/site/test/navigation-ontology.test.mts b/site/test/navigation-ontology.test.mts index 73e81b8de53..a5353d045db 100644 --- a/site/test/navigation-ontology.test.mts +++ b/site/test/navigation-ontology.test.mts @@ -7,7 +7,7 @@ import { objectAdapter } from '../object-adapter.mts'; import { SEARCH_OBJECTS } from '../search/search-objects.mts'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { readObjectDescriptors, readOverviews } from '@cssearth/objects/node'; -import { distanceDescription, isExtendedClassification, isHostedDescriptor, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { distanceDescription, isExtendedClassification, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import { parsePreparedGalaxyCatalog, resolveSpatialCitation } from '@cssearth/catalog'; import { resolve } from 'node:path'; @@ -16,24 +16,25 @@ test('every scene and every package the host draws has exactly one searchable de const descriptors = await readObjectDescriptors(resolve('src/objects')); // A hosted package's entry is its own descriptor's, as a scene's is: no catalogue row names a destination. const hosted = [...descriptors].filter(([, descriptor]) => isHostedDescriptor(descriptor) && isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.catalog !== undefined).map(([id]) => id); - const overviews = await readOverviews(resolve('src/objects'), 'sun'); + const overviews = await readOverviews(resolve('src/objects')); assert.equal(OBJECTS.length, SCENE_OBJECTS.length + hosted.length + overviews.length); - assert.deepEqual(OBJECTS.filter(object => object.kind === 'prepared-focus').map(object => object.id).sort(), hosted.sort()); - assert.deepEqual(OBJECTS.filter(object => object.kind === 'overview'), overviews); + assert.deepEqual(OBJECTS.filter(isHostedObject).map(object => object.id).sort(), hosted.sort()); + assert.deepEqual(OBJECTS.filter(isOverviewObject).map(object => object.id), overviews.map(overview => overview.id)); + // One object can be more than one thing: the Local Group is a level and has a place in the world. + assert.ok(isPlacedObject(requireObject('local-group')) && isOverviewObject(requireObject('local-group'))); // 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))); + assert.deepEqual(new Set(SEARCH_OBJECTS.map(object => object.id)), new Set(OBJECTS.filter(object => !isOverviewObject(object)).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.ok(isHostedObject(requireObject('m31'))); + assert.throws(() => requireSceneObject('m31'), /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(isHostedObject(object) && 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(object.kind === 'prepared-focus' && isExtendedClassification(object.classification) && object.worldFrame && Math.hypot(...object.worldFrame.originM) > 0, id); + assert.ok(isHostedObject(object) && isExtendedClassification(object.classification) && Math.hypot(...object.worldFrame.originM) > 0, id); } }); @@ -51,7 +52,7 @@ 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'); + assert.ok(isPlacedObject(cluster)); // 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'); @@ -83,9 +84,9 @@ test('physical hosts remain distinct from scene hosts and M45 retains its measur 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.equal(isOverviewObject(OBJECTS.find(o => o.id === 'milky-way')!), true, 'a physical host does not fabricate a catalogue destination; its page is the overview'); const m45 = OBJECTS.find(o => o.id === 'm45')!; - assert.ok(m45.kind !== 'overview'); + assert.ok(isPlacedObject(m45)); 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-test-values.mts b/site/test/navigation-test-values.mts index 1df07f26344..4f439943f1a 100644 --- a/site/test/navigation-test-values.mts +++ b/site/test/navigation-test-values.mts @@ -27,7 +27,7 @@ export function navigationFixture(frame: PreparedWorldCameraFrame, capture: () = 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..dc88325cc04 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, isHostedObject, 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(isHostedObject).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); + 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-navigation.test.mts b/site/test/object-navigation.test.mts index 080dee823b4..009c86b27dc 100644 --- a/site/test/object-navigation.test.mts +++ b/site/test/object-navigation.test.mts @@ -13,7 +13,7 @@ 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"))); + assert.deepEqual(new Set(SEARCH_OBJECTS), new Set(OBJECTS.filter(object => object.zoom === undefined))); assert.ok(SEARCH_OBJECTS.some(({ id }) => id === "sun")); assert.ok(PLANET_NAVIGATION_OBJECTS.every(({ classification }) => classification === "planet")); const unknown = [ diff --git a/site/test/overview-context.test.mts b/site/test/overview-context.test.mts index bd5bf0446d9..3a9697e1841 100644 --- a/site/test/overview-context.test.mts +++ b/site/test/overview-context.test.mts @@ -88,10 +88,11 @@ 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 { catalogueObject, isOverviewObject } = await import('@cssearth/objects'); + const beyond = catalogueObject({ 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: [] } } }, sceneHostId: 'sun' }, + () => async () => { throw new Error('unused'); }); + assert.ok(isOverviewObject(beyond)); 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/src/objects/local-group/object.json b/src/objects/local-group/object.json index b86d387ea4a..f9215ff1cbd 100644 --- a/src/objects/local-group/object.json +++ b/src/objects/local-group/object.json @@ -4,8 +4,6 @@ "type": "galaxy-catalog", "properties": { "overview": { - "name": "Local Group", - "description": "Our galaxy group, including the Milky Way, Andromeda and their neighboring galaxies.", "order": 2, "zoom": { "enter": { diff --git a/src/objects/sun/inventory.json b/src/objects/sun/inventory.json index f0c7e6148c5..274c3791281 100644 --- a/src/objects/sun/inventory.json +++ b/src/objects/sun/inventory.json @@ -4636,14 +4636,14 @@ { "location": "prepared", "filename": "world-context-summary.json", - "bytes": 865808, - "sha256": "328127e24e9f5e97f7945906c0809b07142b405c19c2d84f6d259e13c9401c39" + "bytes": 865752, + "sha256": "2c9a2897b9818e8c05fcb248c1ad67ba6bde45a524127b78ef110f720c77a4c1" }, { "location": "prepared", "filename": "world-context.json", "bytes": 53191775, - "sha256": "d1187d9725aa35c272a8765e541a86bbd158de0116c1675d68cc1ac68721cd5f" + "sha256": "6cc402b9a165467635ee578c49ebf0a6e0330cd0a9ad8746bedaf33bca7d2224" }, { "location": "prepared", @@ -13445,475 +13445,475 @@ "location": "prepared", "filename": "world-systems/ab-pic.json", "bytes": 692, - "sha256": "12a87e964f3b8cc41d12960acfd477c69a97485dc12f56ae1981d79b60ecf36d" + "sha256": "b8f1429cd1d16de5ad545f15fabbadbf72dfc70ac1905ce7abcffea695048797" }, { "location": "prepared", "filename": "world-systems/af-lep.json", "bytes": 688, - "sha256": "5a94487647d4be3ee7df08d457812a93da4dff804e557f36323d511baef5ee93" + "sha256": "af485d116d9b55cb519946de957ae43495d13d6a15a41df54f8f685173b261d4" }, { "location": "prepared", "filename": "world-systems/au-mic.json", "bytes": 1000, - "sha256": "bc3b9a0fa957bb0903ac1f60e749853bce8665d7bc0a501fc12399e652784037" + "sha256": "1a6ab152f8aa834abd6f50c2f0444dbba7d9a62ea3ddd3604754891718775fd3" }, { "location": "prepared", "filename": "world-systems/beta-pictoris.json", "bytes": 1404, - "sha256": "5f036f77374dbdb0d537c47308fbc6e44b19b34269c253c3b9ce9de159abf1a9" + "sha256": "20642108841b09a6b6d0e0031af7ee8bb11091b31e534b756ebc24a71fc25a99" }, { "location": "prepared", "filename": "world-systems/corot-7.json", "bytes": 691, - "sha256": "d0e379d04219a5a6e04b107b00bfd87bd54936cb139c1849af5abf4d744bf527" + "sha256": "92efbb54d8d4dedc290c8e329a37b22f2fbd7925d6716f17777b8a317970fc2a" }, { "location": "prepared", "filename": "world-systems/dh-tau.json", "bytes": 694, - "sha256": "752a1c0161ad8c6c13ecd1bcbcba1d71a0a4aab295331a7c7ad94ced3cde4538" + "sha256": "a7456f99be965fad2890f6a191ce9a8c6103333c2692d0be8b8ddf9b386c9fb1" }, { "location": "prepared", "filename": "world-systems/ds-tuc-a.json", "bytes": 698, - "sha256": "a89739de922d9bd465bc746a2dd324a993d75914fd975b96e7d71c66aaf91013" + "sha256": "0a32dd639187971b09e4ef6c8375489f0ff85a3c495a3926415ff86cabea0120" }, { "location": "prepared", "filename": "world-systems/epic-211945201.json", "bytes": 738, - "sha256": "bf9064fbf63d04a80fb1a0c3cdfecfd97995dd16569af43d55651234b160de67" + "sha256": "969e8af4bbf2c68cacb7c766c1df8d1d9e6ce09a6c428c57e1d9180871e70148" }, { "location": "prepared", "filename": "world-systems/eps-eridani.json", "bytes": 632, - "sha256": "dff9196d238d4081998f1feb8b63c83f126c56abda84467b11d82c0aae2125fc" + "sha256": "1a68dc2a1662c659e93c508598a5c772ca9dde1df651cb7d0d404a0e9f3a5880" }, { "location": "prepared", "filename": "world-systems/eps-indi-a.json", "bytes": 711, - "sha256": "824b6191893a660d4cf50bda2cf15d68c9e72f5f930c5bb1debb856188c37bd4" + "sha256": "4ea46beb25e387277a848e1d2483ec2857320b83600a6cd0032bdef6e72ad0e3" }, { "location": "prepared", "filename": "world-systems/eps-indi-ba.json", "bytes": 897, - "sha256": "40fd2904630bcaea5464abf5c5025f68d6a1036c46107370c65fa83c9df242e6" + "sha256": "f831250747ad6fb7f3c28424350631d5b702eb35a0ce77bdbd7f47a76238eec6" }, { "location": "prepared", "filename": "world-systems/g-9-40.json", "bytes": 683, - "sha256": "fc6afda8ab177df8d32b895cc1532dc2a25241e954f063c7a8d7eb62def64dd2" + "sha256": "60eefee3f649fc5d5a5a9dcfc6d787295dfe07c61396b7a010e85d0efa8be8e7" }, { "location": "prepared", "filename": "world-systems/gj-1132.json", "bytes": 690, - "sha256": "65d917839c21fb8010972486f05dac59f5c049d0a043da28fb0e4739f9f40087" + "sha256": "83b254770cc44fc702183d86d45ca07464e564b4319089831e2dca10998b0eb9" }, { "location": "prepared", "filename": "world-systems/gj-1214.json", "bytes": 687, - "sha256": "76e17f937943a83ca4b5e2fe88ed66eaa9e33db6d5da1db577b7b007b2e541f8" + "sha256": "3c9cd4af4e3c6a922a9b19adfed35fbce8f7944f887f925d4cf22b5334460a17" }, { "location": "prepared", "filename": "world-systems/gj-1252.json", "bytes": 690, - "sha256": "a2a9a672759a6c34ff6775e45760a6be0beebf6969735cd059dba9e20c161b69" + "sha256": "b048c01f749231c4371c591347600245f95e27aa7d72f06a7f3d3bf19398d436" }, { "location": "prepared", "filename": "world-systems/gj-143.json", "bytes": 993, - "sha256": "9b25630314c7bbd49f252ed0e0c486433c242ae160c24c332e1692f2102e43a8" + "sha256": "5281b044b1476b176f21ebc68310e5c8a1ac8a0bb6e411ff43f0bac8224eef9b" }, { "location": "prepared", "filename": "world-systems/gj-238.json", "bytes": 682, - "sha256": "246fe553c03c73d1b37d024f7ebe81eac312c0ad83a65b95bfcf288fa89728e1" + "sha256": "733811991782425655f4554bda65dcf0a8234034deaaff0b8b8e1c913750b2bd" }, { "location": "prepared", "filename": "world-systems/gj-3090.json", "bytes": 689, - "sha256": "ffab52ed9ae9287a2b5445a7d284f6e53d70fce7f433aded4ca2b715456a5fea" + "sha256": "05c7e924971b294a54fb7051054ea37c939c6a398c0f1febc0094bc8b19600ef" }, { "location": "prepared", "filename": "world-systems/gj-341.json", "bytes": 684, - "sha256": "992bea3b282beac0dcbe59f4007f0bba99380e83696e1a10bab155bda03f8224" + "sha256": "6e3399fa8d1d9569bb44196e8efea21185b06f0dec0887319e2d4a5ffbcee6c2" }, { "location": "prepared", "filename": "world-systems/gj-3470.json", "bytes": 689, - "sha256": "be4a778ead1e7f946d7c3cbf37d00a3fab614add8af973d3b9a6561e0830a19f" + "sha256": "8b9fd5a3eff2a3d28f11e484db7b3568f425c4478178e24def9609ed91f34589" }, { "location": "prepared", "filename": "world-systems/gj-3473.json", "bytes": 686, - "sha256": "28ab94786cdde9892cdbe689d39ccfcdc9f36f6910121c77c106bf984496c8de" + "sha256": "71dfee50ab900d6f5c7ea58e4bc41369947ef5fa35d94b74c5c56fc8ae8f68be" }, { "location": "prepared", "filename": "world-systems/gj-357.json", "bytes": 684, - "sha256": "bcd5f726ebb2dc2db148a0ccd277fea716b4e5f51b2cdd84bbb604af51f6f4bb" + "sha256": "779a76efc0f8260117c0ee82d8d0fb436cf142ff07043245f3ce94e36669159b" }, { "location": "prepared", "filename": "world-systems/gj-367.json", "bytes": 684, - "sha256": "cdf2e892815348df8c4d3897e036d5eb367c158d39a4d6b39dfa64485fb91edd" + "sha256": "b4c0ff49c4c9589d60dde7b277b63d27d37238c01c307fd933530321baa2d219" }, { "location": "prepared", "filename": "world-systems/gj-3929.json", "bytes": 690, - "sha256": "0d9e58b3fda025ed189836c8b0011be2295bf4175e09c12b63386afd833529f0" + "sha256": "20e5a88f1ac92bc05cb2bcc2059db299c6f12f367b32a5ef5bde4077bb9317f0" }, { "location": "prepared", "filename": "world-systems/gj-436.json", "bytes": 679, - "sha256": "910e02c2518ac216a91f224171adeb7e99a5c0540b767b001f0fbab51e3edef7" + "sha256": "73efe46a234dad31992473d2ec39863f8ecc7c7566f3b45b847cc81e04fdd644" }, { "location": "prepared", "filename": "world-systems/gj-486.json", "bytes": 680, - "sha256": "2912f9129d1b9c842b2689baba9b8bc1e4beae11155426bd858ca0c8ee05f414" + "sha256": "0f54a3f91f6bceb0a326f5c737ab6780bece2e6064c0e0ad690c96a31a315f8a" }, { "location": "prepared", "filename": "world-systems/gj-504.json", "bytes": 697, - "sha256": "2d0fd475e458926d68d5b75f82e6e1a560b123eff668b1f3b99f78d1947b7aad" + "sha256": "96439f8de0fe8c3a9943d1a3896ab3507b7f972b38993f36f606efe7466f2a56" }, { "location": "prepared", "filename": "world-systems/gj-806.json", "bytes": 682, - "sha256": "b322edcb60b9bf7484cb1db88c97ee8297a32b718f5647aa3789bc5d1a45fb56" + "sha256": "64a8dcd8594fdc7860c5246c5e3d0b15b6094cd052ceed9ef4f76b354e915bfb" }, { "location": "prepared", "filename": "world-systems/gj-9827.json", "bytes": 1309, - "sha256": "abf11368906b74f600f3b2f8b3d635191a888a55605ee78254016476a47d01ae" + "sha256": "ca490d1dd7b8223bc96b365a5d9aaa5f2798c6e6d0159f5445d812283392c551" }, { "location": "prepared", "filename": "world-systems/gliese-12.json", "bytes": 695, - "sha256": "05d33fdd50cbd5966f563bf33ac476ccfe1e5cc5514278d461b2075a473fcb62" + "sha256": "259a119592531aafd7337ad10649f7e2bbe3c70e3869828b459df8d0769f6bbe" }, { "location": "prepared", "filename": "world-systems/gq-lup.json", "bytes": 700, - "sha256": "12a055e04d93c888bfc1274def263f0a45ac4f7178efa1666a35fb0d04c9a44e" + "sha256": "20d65b4a9135e04d111c9bf62213cdcc5c5c9c31c6e9c73337ed1c70978c3135" }, { "location": "prepared", "filename": "world-systems/hat-p-1.json", "bytes": 695, - "sha256": "fc584e536d329353d74b2595638ad47dd43d2b2704f86999702342d32a71a42c" + "sha256": "40495696a1c7eb5b9f18c3f35d535d4f118213a92181432f3c9a34a3f44aae76" }, { "location": "prepared", "filename": "world-systems/hat-p-11.json", "bytes": 695, - "sha256": "ed641f538e893f6085876bd448d597005c41fef4a86ca9ec4e94373ee6166c83" + "sha256": "a659d019c1ea20f80219ca8a8f0c47e6f77a8dacf32cbda0c0f58310bad8b481" }, { "location": "prepared", "filename": "world-systems/hat-p-12.json", "bytes": 703, - "sha256": "99bdccf622c8c0a6c5c5df008872a77da21c4a8752b8e754f8a404ccb1fd1933" + "sha256": "ccbd77d2cc2e4bf7718bd5641e0413b7eda1f46ba27fb3391620f7229d159ad8" }, { "location": "prepared", "filename": "world-systems/hat-p-15.json", "bytes": 700, - "sha256": "59a81a5318b25f7a5a31cf9f6f4347030d7b82ba09944ee88b77b69236633d40" + "sha256": "dfc8125f1f504fdfaba77ea4aeb72073598765356c62a8784e3eebf530bd7b3b" }, { "location": "prepared", "filename": "world-systems/hat-p-17.json", "bytes": 702, - "sha256": "012d83561e55ed4af186626b56e2fffc8ec3fe1df9aa8434b147762216283d0c" + "sha256": "6e2ec393387e3fa75f75308e155d256b734133b954184ee4d5d955885f5a9f6a" }, { "location": "prepared", "filename": "world-systems/hat-p-18.json", "bytes": 701, - "sha256": "73943b86a74aefbab951fe29978ecef224462bec1f3896768823a44150f4019a" + "sha256": "4291ef87b71bb4b28a8ee6e2d72d01d8e383b6542abdf7e1964f74136953206d" }, { "location": "prepared", "filename": "world-systems/hat-p-2.json", "bytes": 698, - "sha256": "56b8fdb0cd9b20444ad54a77ae84320394a675cbfaee6b76666be414b6fa507a" + "sha256": "90c2adc79a01c9a5358cb0ba8749ebcc8d03a99898e3d9c7d19094cb74cf19be" }, { "location": "prepared", "filename": "world-systems/hat-p-20.json", "bytes": 697, - "sha256": "4ea0c756c291550422ef3b3c9dd9f5f51743f994347e743eabc29b3e290a5f16" + "sha256": "f258599d0bbd55697db517956860a20d00a5c67a35e4bd31545fe863fcc34e21" }, { "location": "prepared", "filename": "world-systems/hat-p-22.json", "bytes": 699, - "sha256": "e8f4c3de7892e5a0fe0ec48046f8b852ebd42b9554b16c272c00de308c82d601" + "sha256": "bd56e39294d9fc1ddee8605b73a325920bfc3a0a0737e718736583c54f630d16" }, { "location": "prepared", "filename": "world-systems/hat-p-26.json", "bytes": 701, - "sha256": "ffce525e8fb4957e3446bff4913d250b7ffc99aa85b6ef1a1c427f972fda631d" + "sha256": "f7d3634518896834c7d67cb254c93232e9d4e941e99d0416676432fa09c9da98" }, { "location": "prepared", "filename": "world-systems/hat-p-27.json", "bytes": 701, - "sha256": "759b83795b1dfb6a0fa861a107e3a53a5b82c487acfd3e2a5c6aa29546291cc0" + "sha256": "4f96477d790b25377b9a2c6046245a4da15790368a61b9ae06d510e8eb0acc35" }, { "location": "prepared", "filename": "world-systems/hat-p-3.json", "bytes": 695, - "sha256": "2437cdfb7d7de87eade59a3048bf51b13a7c98081e04b34130a9ead812e634c2" + "sha256": "db3b2c0cbe3a4892de9ebb3ad5672de3693b52e62639583526ede8103f4bc1bd" }, { "location": "prepared", "filename": "world-systems/hat-p-54.json", "bytes": 699, - "sha256": "f3a10459f565439f327b7621bfb963dd6cbc6cee3e8a9de27d9b38aac0f765d1" + "sha256": "750fe5cfd439510fef85c222c953351a70e63b96f42075399f81321865af3557" }, { "location": "prepared", "filename": "world-systems/hat-p-70.json", "bytes": 700, - "sha256": "85e19c025e56aba7867a29049cc6e2cfc7ff94e23284ffa385a714b781a93e7b" + "sha256": "e3c58936790017d0b47dbceb94ef6fad400301f5afb2be86d0b075db714418c9" }, { "location": "prepared", "filename": "world-systems/hats-6.json", "bytes": 687, - "sha256": "7c93686eb6a40ea3056d045b7922805b60570f9fb017e7e32c5da9b5d593aa2b" + "sha256": "490caa2a5f5e6d18003c11f315eb8de307204f91170568695d962bc9158234cb" }, { "location": "prepared", "filename": "world-systems/hats-71.json", "bytes": 693, - "sha256": "2af89520774d60b7127b14c266d0826434ead56130b9cdd9734600c791c01c44" + "sha256": "fab00deccc19d3271af11fbf1050aacbf5a1e313bcc6fc12b49bff636a513e59" }, { "location": "prepared", "filename": "world-systems/hats-72.json", "bytes": 697, - "sha256": "d1a2bb7ca3c3ca8e93140edcd3a1fd131c0cdc9eae0361bc49e330f025905f07" + "sha256": "17ed252d5341070a2e8add0e892b03437082370b8f74f1c6ce6e7aa993536979" }, { "location": "prepared", "filename": "world-systems/hats-75.json", "bytes": 695, - "sha256": "cf4715a216f645e3799e515087f3bde94289979c7cc8d9b04a28d8a91ad71591" + "sha256": "63f7dd7c4b34656fd32f2a6ecae77cb9f58419ad21036b78f3bfa123ed69fc81" }, { "location": "prepared", "filename": "world-systems/hd-106315.json", "bytes": 706, - "sha256": "fda0c7f00ab9a5fd4667f77e487cb4fb4e19240ddae609f3a3cd481709f6fc47" + "sha256": "2668806d397db08458ec04c806a6786f598f192dea353d8845d25c745d6f1d0d" }, { "location": "prepared", "filename": "world-systems/hd-108236.json", "bytes": 1692, - "sha256": "fd197b84be611fdf3011ffae3a9d7e4a5c47c9f029ba3c35cf2809ac2001f251" + "sha256": "917c236e47ac47f0fa187374d96a9e89794bd4ff4ea89b1d31bede158218b684" }, { "location": "prepared", "filename": "world-systems/hd-109833.json", "bytes": 708, - "sha256": "fdee7853ef68e5ae182d7d941d1c859c6214d17616416225cad780a48466df9e" + "sha256": "ebc83a62a2d61dbdb2826388e3e9eb4be3ea0b84b8b8604b4653c79fe3cbdfce" }, { "location": "prepared", "filename": "world-systems/hd-110067.json", "bytes": 2314, - "sha256": "c0ee742e7f4740c831c645d60a2adc3b89a550c50e82eb4184b7c6dcc71b47e3" + "sha256": "c412baf0500406a39e31054542fd967d5f0ec51920be628adb26a2dc6bc0aaf7" }, { "location": "prepared", "filename": "world-systems/hd-110082.json", "bytes": 706, - "sha256": "d4746a7b6e7a58d03cfb3e35d219c57d5bb8e31a78de5e49bb9e7f00c315311a" + "sha256": "893739ae4c6c38c2c9a1e07fa7f331cad6aba1e8a69a358c954765214fd40969" }, { "location": "prepared", "filename": "world-systems/hd-110113.json", "bytes": 709, - "sha256": "9cf878c1cac1618ebc5fd007df803f16fa3d01ec3355c95f812c30dbae0de40b" + "sha256": "8e55c39246e59c610b1e9409379892e6075b65232ed52631f63bb8ab82e1d2c2" }, { "location": "prepared", "filename": "world-systems/hd-118203.json", "bytes": 708, - "sha256": "a8567b339c00cfae467a59fb80ddcbca03cc4dab4007db59951dedb7c6e2a0c0" + "sha256": "c98513fa3a7eae81ee4bf97a9a881c2374d47612960cba0fdcdca7f54f270ef8" }, { "location": "prepared", "filename": "world-systems/hd-135344-a.json", "bytes": 726, - "sha256": "8effacda2b4cc90225233c9bb8a98e7ee11c6b6805a80c1b9aa732560db622d5" + "sha256": "b53548bfdc7ef9cba4cfb4f41eab05b91824994d082bbf87d2c001316d46f730" }, { "location": "prepared", "filename": "world-systems/hd-135694.json", "bytes": 706, - "sha256": "0b10cc061af674018324da493cace93cd6d6e1827f1e658a900e9f483a1301a6" + "sha256": "8079a8208e4f2f970b83a5b6c9cbb1d5964e5a0334d95b2f4560109f7a722e1b" }, { "location": "prepared", "filename": "world-systems/hd-136352.json", "bytes": 1354, - "sha256": "f93a5a417f8d608bda2ca14591bb757cc81a0c9db431d11763372ce7939d0201" + "sha256": "e0d5a4024fdc39c75bee886f57ed05e30d11db526ef28ec7b21facac3681a001" }, { "location": "prepared", "filename": "world-systems/hd-1397.json", "bytes": 690, - "sha256": "2f1acf418a7e8ae6758612ff1ea26eec557cdcee461febe1333e9d9315829681" + "sha256": "17c1a57b752a3fc002629cabd2cbdf11dee3a93f1982a87aeb13d6d972ed5d62" }, { "location": "prepared", "filename": "world-systems/hd-149026.json", "bytes": 705, - "sha256": "fc87aeb84b4feb98dc2c6bde536e0003c8604db852ad67a2c95edcbb361cf32c" + "sha256": "e9a2405da947e3a45e9c09c87f869d7baeeee29a60216e8910dea169467e3740" }, { "location": "prepared", "filename": "world-systems/hd-152843.json", "bytes": 1034, - "sha256": "533d188e763397ab40da16d2cf30fb526973487fa19130db0881588be8e48340" + "sha256": "9f4bf2b3334a9de87686b58f1dc5dbce55c0b9b70a3d7e24fd40e3247c9492cf" }, { "location": "prepared", "filename": "world-systems/hd-15337.json", "bytes": 1011, - "sha256": "be44ec6ed7e1ffa72d9ff3f3af9c69058a56f8801e91c4e9f8f31286bb856491" + "sha256": "d30df16d0f8b3cbbc89de2f282e17990c21b9286f50028e069d042d6e96be97a" }, { "location": "prepared", "filename": "world-systems/hd-15906.json", "bytes": 698, - "sha256": "e4f05234937e31a8f80f7b16a3593083063e1a2d8a862166099b8b86c41df6e2" + "sha256": "79cb768bc44547acc9861a9a40373582637087e0a6a551438da605e8d980c29a" }, { "location": "prepared", "filename": "world-systems/hd-17156.json", "bytes": 696, - "sha256": "bd6d158cfb2ad86282f57f0a8a237ffd65669e7ff72db86ec750b62938ba22ec" + "sha256": "2278eba7d5797a5c4abfe1e14d1db8ee94a09234d95486dc7b445d85e4fa82ae" }, { "location": "prepared", "filename": "world-systems/hd-183579.json", "bytes": 706, - "sha256": "bf0d0708c1006185e3c2d453255b35b7a130067ab958cfa49a5d5e77a8bb543c" + "sha256": "e2ecd1d8c6374336bc9a51b5ee2dfa658c73d2a1ee0892d5185a31acc209775a" }, { "location": "prepared", "filename": "world-systems/hd-18599.json", "bytes": 705, - "sha256": "e5abe8643f9b59cabd1f04f717a6e68ebe28c0630ffcb1df3b37844a2eb0e470" + "sha256": "97a94ba144fe5d64d5dbb1cef5937430b98087049477e363ac394f8ae88006d0" }, { "location": "prepared", "filename": "world-systems/hd-189733.json", "bytes": 730, - "sha256": "e9822f23b31c1d3d1cf004002c8a0689f5fdea0e82590d29b727e5bcd300c9ae" + "sha256": "ca25d592745d60f6ea4cdd445458fb5a0f40b2edc26aab0a4ca02475e07a9e01" }, { "location": "prepared", "filename": "world-systems/hd-191939.json", "bytes": 1348, - "sha256": "71b83239d6c049842fee94cc780b9af2860414a35b9debf26421e8b10d75415f" + "sha256": "738051846bc134400780b055df0409ad9d558e1afe927c2767f66070172fbee7" }, { "location": "prepared", "filename": "world-systems/hd-202772-a.json", "bytes": 722, - "sha256": "66eb341f2a5f168c7a58bacbaf0365ce46650744084834afbd27307787456bc8" + "sha256": "04645da720b8253751e63dc167087c0737e9af88c61ec7ad72ce6f1db8625f1c" }, { "location": "prepared", "filename": "world-systems/hd-20329.json", "bytes": 697, - "sha256": "3250e2e9b428a8dc087f91cf005d9c7385823b9044c991a7e304190339e033bc" + "sha256": "461d318d44ab19a2067309d149fee346eb81d00c06398f73e6317cb67b8196f7" }, { "location": "prepared", "filename": "world-systems/hd-206893.json", "bytes": 1039, - "sha256": "213ded9bd596da807fc1186a23d12b38bd591dcbd26cad900c24d38cf6a54b21" + "sha256": "814cc93d1b5e8c90667560fc95307c369bec3384c6ac61a5ae46dc38f2eeab32" }, { "location": "prepared", "filename": "world-systems/hd-207496.json", "bytes": 702, - "sha256": "015477d18fdb00f2e41b12faf80a382e07f132380f5ce36d37be77f7be1bf074" + "sha256": "a39606205a0c83e024d55f8b7380f806c5d1ba8140b8ab367fb6556627a22b8b" }, { "location": "prepared", "filename": "world-systems/hd-207897.json", "bytes": 698, - "sha256": "f3a8a3124a0db99504d002ab7c60d3d44aff470fc579ac694cc8887ec2ed07cd" + "sha256": "cb0543dc3fbd7503f2490c561f14c4968704f98c51115b8e2d4c2525b78c265a" }, { "location": "prepared", "filename": "world-systems/hd-209458.json", "bytes": 701, - "sha256": "94497d86a1899cfc683b4c06c549addc083e6284d26f1e854969f580243e44e7" + "sha256": "ef7b070bd81548dd2c95aa8251ee086a5757f0d175381d95db75754b8a4a32f8" }, { "location": "prepared", "filename": "world-systems/hd-21520.json", "bytes": 702, - "sha256": "481e28ed810d55a9b8d30966b5b69d4b83c5f77801fbd056a0b8ec97bccc5519" + "sha256": "b97a30745a1a3eea4c14bb50fa92a2d645398616d17694dd46c1c2affe62175e" }, { "location": "prepared", "filename": "world-systems/hd-219134.json", "bytes": 1015, - "sha256": "ecc419eb099d727987e7c471c2f54f2d46e115f6cb0cc065d647c47f3d78fe6a" + "sha256": "6b588a97e9bb894654587f870f7c9d561e791c7394289e7bb364cbd6ade704bb" }, { "location": "prepared", "filename": "world-systems/hd-219666.json", "bytes": 707, - "sha256": "5a52839d07c7d3d1f12d0cb00d4b8210446edad9113d1267e371d204b62fc46e" + "sha256": "e4ff25329f5ea265388812d658b7b4da18c014c899e158dc7cd98ef40dc23260" }, { "location": "prepared", "filename": "world-systems/hd-221416.json", "bytes": 708, - "sha256": "ffaa2cd3486ec9553e4c62fd69ab59eaa708766687f3886acc3502cc0a2ad22c" + "sha256": "574ea6332768b4a2217a0e7593b02fd22c2d4e9f6bc6f70af3a62f7a57d29d9c" }, { "location": "prepared", @@ -13925,3787 +13925,3787 @@ "location": "prepared", "filename": "world-systems/hd-23472.json", "bytes": 1966, - "sha256": "0dd333f2ffaeddd022c1d285011fe5db7ef8917f24196a577d0016fc778573e1" + "sha256": "40d88da4b5a896491883aacb3b459cd309b99639abef622f8dbfb4f1e9ae83b5" }, { "location": "prepared", "filename": "world-systems/hd-235088.json", "bytes": 704, - "sha256": "da8d1e3e338aeaa2f6d301ddbf59d330975fc6a7d4d803747da8101eb24f4de0" + "sha256": "cb8acbd7b0aaccefc78495c9d370cb37ef36b3708cfd6e257238cdb0a7e38ac5" }, { "location": "prepared", "filename": "world-systems/hd-25463.json", "bytes": 696, - "sha256": "ea8f3adce7e64ce61ea4d79eba6de8dd52220f53fa4222d801cb0fb500d0a30a" + "sha256": "81d9c193d16a4459fdaf879bcd77e17ccd8a3a2b5b207e14bbf10df24486cf74" }, { "location": "prepared", "filename": "world-systems/hd-260655.json", "bytes": 1010, - "sha256": "0ece4635830d0657bc5dd50a7853cc4be2a2745ba89b6244b6159606852d1f08" + "sha256": "d4a8ffe0a03b7f8e5306a50a16fa6de2f6767cf463d28badc4f0a237cd56ebaf" }, { "location": "prepared", "filename": "world-systems/hd-2685.json", "bytes": 694, - "sha256": "3c7970e895306836c1aa3c2637a18a77d786a491f9f6f6dbd4b228a963e4b9c2" + "sha256": "43869add4081f76a1dbb0d99f84c91fde33b53b04fc785b1f8672545e5a05df4" }, { "location": "prepared", "filename": "world-systems/hd-29391.json", "bytes": 702, - "sha256": "c98a823b10cfcb663537ce944d50b3801d808734a1e384cb70bf49a151fe57b0" + "sha256": "e31477919d0df54e7882054b6aa10365226156551f9622f41ede50accb9958bf" }, { "location": "prepared", "filename": "world-systems/hd-3167.json", "bytes": 1001, - "sha256": "3d296e33f9b4eb7eb57603006ecbceb9baeebaa24dc304fb82aa4f967032cb00" + "sha256": "dcf830cbb25e1ec8924b5e45bd8ca3ce23b5e71db508042a0894280fdbbb9d9c" }, { "location": "prepared", "filename": "world-systems/hd-332231.json", "bytes": 708, - "sha256": "0aafdf8f67833188e2f499f12c51c6e614fe2355668ba9699ce0454c04387349" + "sha256": "937154b02372b04c56d531ff56c9cfab1c7073ec5a78513fdcc7ac8ccce1e5e2" }, { "location": "prepared", "filename": "world-systems/hd-35843.json", "bytes": 700, - "sha256": "e537dce4921104be2c57ed55a4e9767a082befa5bd3160237bc44c19755a90e1" + "sha256": "0e9cc65a68d017154f32fce65a4272bb95fed89a58b42b21eb6dfdcde8810179" }, { "location": "prepared", "filename": "world-systems/hd-39091.json", "bytes": 684, - "sha256": "724a34fd99b2cf8589223583db121f6e582e302e22cb47ff4a48ea7495ccec5e" + "sha256": "df3043fe14cd832059929f568632b6272cb0164075ed0f0528acdd5f18b90787" }, { "location": "prepared", "filename": "world-systems/hd-50554.json", "bytes": 1019, - "sha256": "11b2de10b8524f25a78ac55e9cbc28963405d064aa0c8051ef2665e2106306f6" + "sha256": "2c1ac2464a631cd88d270ef878776f175309c268834df6155fe9107df24fdffa" }, { "location": "prepared", "filename": "world-systems/hd-5278.json", "bytes": 690, - "sha256": "c7b63963f670a78da8cd5f18decadad43fe1f4f5f5df0d3983085ede8fe70bee" + "sha256": "9277602ffbc0926cdc754954f0e082b2e9346096bcbd3bab3a9d1be25ebe014a" }, { "location": "prepared", "filename": "world-systems/hd-6061.json", "bytes": 691, - "sha256": "68b56d62821cc5a1c9863059f8c013b9287c182ffc25c4b1516e3e2f152f4842" + "sha256": "ce6d41fb1be44b642227fbabdf12da097432291a8b06b5d6f0a90bfbee42d66a" }, { "location": "prepared", "filename": "world-systems/hd-60779.json", "bytes": 696, - "sha256": "c8fff456b0959702c54eaa9aaf2b81b4b57019c0f02594082f8417764d573284" + "sha256": "59cffd2887c798b301a67eaad4efeeb14b27ef9c9dca41c7c78e73b8a7c98f87" }, { "location": "prepared", "filename": "world-systems/hd-63433.json", "bytes": 1326, - "sha256": "c9ff009e4f47c7b740c253e8b3e9b4be8e9d36a1c299ab2706712fca32a45d7d" + "sha256": "90ea5aa14f5895d7195b5938e615d7de08bd0c2d8c8ffb0fc0bbdf10ba839362" }, { "location": "prepared", "filename": "world-systems/hd-63935.json", "bytes": 1016, - "sha256": "4001ef0432cef0ba318c5ad3acf9a3cbd0ada398ca6fa0753b75a21044e5630f" + "sha256": "711bf83f2081248d53ac611b833c17bcfb351c37fae234a37d25e0db9b589424" }, { "location": "prepared", "filename": "world-systems/hd-715.json", "bytes": 684, - "sha256": "ea408b97b6636f23677c72ecb87e5ed8dd3795ff56fc397cfda98e770499a934" + "sha256": "51d0e132806427b2743e9391260c29b5eae5afb3f9405eff3b463e833cdc13b1" }, { "location": "prepared", "filename": "world-systems/hd-73344.json", "bytes": 696, - "sha256": "45276fd9c048b7f6d2e4b19b0fc971dcc33325f3846a94c8f86efa4a856bb5fd" + "sha256": "142c734789d59c5e8abcf56586071bde2cb5c21c5dae73a40ea25b31cfbd24c7" }, { "location": "prepared", "filename": "world-systems/hd-73583.json", "bytes": 1015, - "sha256": "a104f88c0dde467821148ff53237919847295128f822f90525e3cdfc8c76d4e8" + "sha256": "d9bba78c781c0309d2ec5e92273b467c4c0abf7d1fe84d91ef9e811c0aae8695" }, { "location": "prepared", "filename": "world-systems/hd-75732.json", "bytes": 689, - "sha256": "60b0ea2924adfc3e771fb94b486efe0caf4e1c8402a4f040313373ff54feca35" + "sha256": "1d818dd0409089830e413034636895d9718373039fccadec6f0a3ea71c9e7934" }, { "location": "prepared", "filename": "world-systems/hd-77946.json", "bytes": 702, - "sha256": "8fe8400662bd3bfba3bd4659cd327c61a9663c171cc966518035856ff2c00160" + "sha256": "fc0bb970ab5b3729f0cb066d54bd6c66bd361e7074771e003981ba25d65746fe" }, { "location": "prepared", "filename": "world-systems/hd-80606.json", "bytes": 700, - "sha256": "04209f21324ec3ba04c279186b499b9974ade3f23e007c031961732050015ceb" + "sha256": "6d1048f19b915429645a525479a527eee7d169b0242964e44ac2c8ffeb906650" }, { "location": "prepared", "filename": "world-systems/hd-80653.json", "bytes": 699, - "sha256": "617d4aa62aa125b99c2a36e1c86f7461f6d180a90c5874c6e2d7a76394946018" + "sha256": "64b9267c69f05e0c3e5a6e9000ff74fa2668a6a15f20f73dc3c7607c5519d2e3" }, { "location": "prepared", "filename": "world-systems/hd-86226.json", "bytes": 699, - "sha256": "8f762f3d5a7fc493886429c86ea496fddb6b994a60990124b13fe788c553c407" + "sha256": "9b0bd95b7d1dba114658f20125dc510a8599eb6d982f5a29314cc51ca3d9d50f" }, { "location": "prepared", "filename": "world-systems/hd-88986.json", "bytes": 696, - "sha256": "311e4d4190848e3d79455b1b428807c6cd43b932e4e59fc83408c4790b10ea4c" + "sha256": "6038f10e90ac067c5d5174f20a562bf53da8b7c9fb62160985783072fa64a666" }, { "location": "prepared", "filename": "world-systems/hd-89345.json", "bytes": 700, - "sha256": "be413fff37e050c520689c874d3a0fb1e9b8181237af58c045b793e9c1b204b8" + "sha256": "bec59d6636d22be8c086713e53463653f56dcee959ce59ba3eb78a9a0889e516" }, { "location": "prepared", "filename": "world-systems/hd-93963-a.json", "bytes": 1039, - "sha256": "a6ad7720b88b5b21aca287d7b8410281ae9413af36bc35aeae9c7577a86d748c" + "sha256": "a2fa5accd40b03492a9ed830688be3c807de1da1607a2292cd63f7fc5030792f" }, { "location": "prepared", "filename": "world-systems/hd-95086.json", "bytes": 704, - "sha256": "3a9df30372734d78c2f1687db281f3cbf4a53b2e5264727db3f9f101856606b4" + "sha256": "e639292c021c612749803e6e8d49368c939275e11766d03f3b4844f5a917e9ac" }, { "location": "prepared", "filename": "world-systems/hd-95338.json", "bytes": 698, - "sha256": "2911e38f7b27cbe289c63c7afbce4d78da39b47ad85ba07c18d1efd243ae87ba" + "sha256": "f8a57fa6ee030895c56c37c952190e75d57b264a48ffb4e36f475e14f094010e" }, { "location": "prepared", "filename": "world-systems/hd-97658.json", "bytes": 696, - "sha256": "edeccf7d245ca613769efd40e48e4aaf2fad963b0225f6fc9b176a8939afee70" + "sha256": "0363b6e778c9a6072a2603ed7a34f2677d2a396d9e9e68b150c484f72c910629" }, { "location": "prepared", "filename": "world-systems/hip-113103.json", "bytes": 1040, - "sha256": "fab4709b1d94f25be89165a10b4d97d9b41a3c09512a3c75ee5e9aef93801fef" + "sha256": "06f49babb7e936057658eb161e0b3b72d1bdaaf7e4065398a8fb8090ccc09055" }, { "location": "prepared", "filename": "world-systems/hip-116454.json", "bytes": 708, - "sha256": "ec2e1830680223f6723b623e783f98f66c4e1a29912d69ebc8a495ece70db8d3" + "sha256": "df8caf5bfc48444c0a81a7c603c8e8b1fc91f1d15174ecb9f6b3e563bd62e09e" }, { "location": "prepared", "filename": "world-systems/hip-29442.json", "bytes": 1348, - "sha256": "f380eab785af518896bd77e77fc77f45b3bc56c08455de3f00ba294d73360810" + "sha256": "5d707ad4e9946fb1971bb9e19372e6a5efa5e8ff9daa8408def11936fa5dae4a" }, { "location": "prepared", "filename": "world-systems/hip-41378.json", "bytes": 706, - "sha256": "a733252bf01a656f6e5fac7d86766f7c16a934c0e9597cd171569bd9e5fe6c7e" + "sha256": "9de10c38f96fc5fc796d1f7dba4bacc36c5010531028ac92bda82be21958d987" }, { "location": "prepared", "filename": "world-systems/hip-56998.json", "bytes": 1018, - "sha256": "bd9ebba2820e5e0de7ab07a925c3e0f8c21b0d042e6ee533cfac76b0d62b9ac6" + "sha256": "8232ee03c078347fe983d4ff0b1e8038cec9f3469aa414934fe42aa1ac371278" }, { "location": "prepared", "filename": "world-systems/hip-65-a.json", "bytes": 696, - "sha256": "d079d3e4f1f402d02fb15eb25a45c11116d46ebbf714aca52ce80e191a9981a7" + "sha256": "7b15051f59725379b0e3fc68cc59ae75a19d07f753518fe92dd5f8d55801d5c7" }, { "location": "prepared", "filename": "world-systems/hip-65426.json", "bytes": 715, - "sha256": "bfb331eea22d5276362a7b2953882b0eaf5b0f8bf15c9c6d8a1902d68b4ed23b" + "sha256": "4d7c554d613a1fc488b40c30fc50c1801327a817d4093890bba8f62b31c070f8" }, { "location": "prepared", "filename": "world-systems/hip-67522.json", "bytes": 712, - "sha256": "cac621a59ad78af7ecb16828ba987a7b840fb86fb0ae3212adab4bc536c98fc4" + "sha256": "95d043ba24736d603c49279c5045ad975979379b66943c5a21009308c5564ec3" }, { "location": "prepared", "filename": "world-systems/hip-8152.json", "bytes": 1020, - "sha256": "d1726c881b2637e4eb3694dd88b336eb544b6b8c10b588b2a867d1162d207f51" + "sha256": "be1689e52d5cdefc2c68e1e460d4b86d7414410a25a72be9e33249caffa73201" }, { "location": "prepared", "filename": "world-systems/hip-94235.json", "bytes": 706, - "sha256": "4291c0f521723a786fe0300457f6c9e511cede84819dd6a36c6724b1853ff004" + "sha256": "b7a1d618fbfd29494f757bf93cb6c1e58937e5375156ea33d281ae21eeae1e65" }, { "location": "prepared", "filename": "world-systems/hip-9618.json", "bytes": 1012, - "sha256": "7add8e4be0938dd6895e2032b50b13571f41d7b44a7479eeedb8872c7fc9c769" + "sha256": "f96cc8c59161d29462a1cc85463e744a47ab5380e3136db69ef61b2337b53403" }, { "location": "prepared", "filename": "world-systems/hip-97166.json", "bytes": 704, - "sha256": "8ac43d2322c73c1e6b1bae214e3bad5a3d9c0372751c8fa481b500f0d5271c33" + "sha256": "f74130c41f828bc6361bac798cd6d6e235ded2d64b4b9bc0f2545d9b22c794c0" }, { "location": "prepared", "filename": "world-systems/hr-858.json", "bytes": 1299, - "sha256": "65b82065cc5d9442d28607087e46f629bbb473987d2c93cbb4757f0e305ddfd2" + "sha256": "0d71b7d851506b1ecf831ff01ae5d67810bcfc81554a9293628a9e76d69c494c" }, { "location": "prepared", "filename": "world-systems/hr-8799.json", "bytes": 1708, - "sha256": "c9204a72f9de631eed00461c494a457c128d0be17e6ad207b830dd7515416adf" + "sha256": "484c61ac3ccd52a8fa6f0c2bd628178c94b49b90938a174f79ffb52e6cc4f124" }, { "location": "prepared", "filename": "world-systems/iras-04125-2902.json", "bytes": 742, - "sha256": "fcc2c53d04911152c7bd27b7d131e1b76cfa646065858a60ef8df19caac6b50c" + "sha256": "8e199e687262d881ce2f464b30d270ae6eabaaec43c6f21f6e730fe7c174ff73" }, { "location": "prepared", "filename": "world-systems/k2-100.json", "bytes": 689, - "sha256": "8c24c10599654e1622786fa59a061447554944dd08fc01e1f513ba523571824f" + "sha256": "8ccca05377b2ad58621b174223795333d9f655d9852e505b48a6876d1e2dfbc2" }, { "location": "prepared", "filename": "world-systems/k2-102.json", "bytes": 689, - "sha256": "9d1342ad3f1704cac4a07fd4f051639953a58042ac1f8b2e7799529d25c67fad" + "sha256": "ca84ef63ca5563434542ac74fb68004ef2b0ef48735f3e8f8e250573c55f47e5" }, { "location": "prepared", "filename": "world-systems/k2-105.json", "bytes": 690, - "sha256": "a285dd43b1fb4bb57dd40a1c17cd779888cb4f7ff8863805f47796b182f175bc" + "sha256": "b78287ba0af1ea6746332f51756649f82f1b1954a59d8dd70c7e02ae04cd5374" }, { "location": "prepared", "filename": "world-systems/k2-110.json", "bytes": 692, - "sha256": "9116a593f2dd302b4061ac3345099a85e2e6fa91e8cc0cf4b7a58ad58c3cfeda" + "sha256": "3c41bad7082c28e709c2c8627cc7b24fa13fdefc6dae2a5053c2f28a1af91e0c" }, { "location": "prepared", "filename": "world-systems/k2-117.json", "bytes": 998, - "sha256": "217eeeda6f1ab6ff09130e7d41f8243998e6e79d4fab0b29bd2fcb50559895fb" + "sha256": "032ebc4ebfc181c64d8a6680f1272eaed41a6b684ea2a5560e8754fb127bf32a" }, { "location": "prepared", "filename": "world-systems/k2-121.json", "bytes": 689, - "sha256": "e9834eca504fb9a179c55f0acc10b56ffd336cf3f8d7961b77071bf00b5262f2" + "sha256": "341e7029dbceb28aeccaa9da4fe692f20a1bf29c54276f3d2627808035600c9d" }, { "location": "prepared", "filename": "world-systems/k2-122.json", "bytes": 686, - "sha256": "642a9a19e6bc6e673f39650faa12b6d1844304b194dc93f172ee075795710786" + "sha256": "2058b63fdfff74629b94517ce6873ea92198f74a0a6320429c3c997447318963" }, { "location": "prepared", "filename": "world-systems/k2-124.json", "bytes": 689, - "sha256": "33d6c932ffb2d9425ea58f6e016614f6c3895bb2c59e33f782f915eb572e59ca" + "sha256": "a32dfdac16d01d95ba28d96faf7692b7fe21ec2dc6721c7d910ab06c7cd51b55" }, { "location": "prepared", "filename": "world-systems/k2-133.json", "bytes": 684, - "sha256": "298b169dd56123a16f5a67c472f40208fa98ffeddabc72aa8931e2e83b492ebe" + "sha256": "47633a8ab3d52ad24a492bb2d3e3ceec875582f63237e19f5cf00aaebdda4853" }, { "location": "prepared", "filename": "world-systems/k2-136.json", "bytes": 1298, - "sha256": "af1be20dde5e689984c1d556360ce15ad50648195c7d561004191a2d7613569b" + "sha256": "79a2c845d70d125c76dea56f70e92b7ce54659bcd30c41701de52ae25b20d119" }, { "location": "prepared", "filename": "world-systems/k2-141.json", "bytes": 684, - "sha256": "7a859ce1146416584142d78493fc2a79a3ce01f5faf85bedbdfb81fad705f40c" + "sha256": "732bf0ab3671ae92c699df2baf1987aceeee322a904f7012b30483490abb8ea5" }, { "location": "prepared", "filename": "world-systems/k2-148.json", "bytes": 1298, - "sha256": "1c1c6e80e159a5e0f13626937db056b658c9e393caf60cc73a5a7280c189a78b" + "sha256": "2fda88f96cb27d15924a31eff1b806d5149a12014d0c9367a559a8edec8c9d29" }, { "location": "prepared", "filename": "world-systems/k2-152.json", "bytes": 688, - "sha256": "b5b09d58dcf6794577a76b26c272659086ba30b87e1d922884deb85d126d1d82" + "sha256": "8058fbdf29e9b6e83bd754c7968d2e93d1347828435f01be17c6d354befab870" }, { "location": "prepared", "filename": "world-systems/k2-154.json", "bytes": 690, - "sha256": "c4a30c380efacbe9d1e24ce0a70c3854fc66eb341da526b0fc8f1557e65ab8f5" + "sha256": "09600748ab09b2c0cfcf64a56c1b4990f146e50b28dbc94d476c105c1483ca64" }, { "location": "prepared", "filename": "world-systems/k2-155.json", "bytes": 991, - "sha256": "faa779dd2cc69970cfe9b2a297c499a43ce56318821551ef097071db7b05c070" + "sha256": "2d6ac64eec459683e3524cf53c622da15a948ce7f3e18381a0284c4aa0278cd1" }, { "location": "prepared", "filename": "world-systems/k2-158.json", "bytes": 990, - "sha256": "6b69bda9809197de508409a956a5a7d0cc374572cb77ef420920f1cbae3bfd2f" + "sha256": "7d4797e445c7616cd550724f1ca4c6034472f978e0d36295dd535b9a18ceab8f" }, { "location": "prepared", "filename": "world-systems/k2-162.json", "bytes": 687, - "sha256": "6faed7177d236d72e80b96b9d7396d72d23d59ca598e073f43e63c235047a6d9" + "sha256": "67f073369629481f112007de4f25994602504692ea76de61a84c2b7b860cbf40" }, { "location": "prepared", "filename": "world-systems/k2-167.json", "bytes": 688, - "sha256": "caf4c51d10149b51c94c513a5c9e93d0390fc48a69308a89722d1f0c01f1f0b9" + "sha256": "9c77664e91835699cae62205c2cce297027b3d98a4fb1bc41407aafe759b169f" }, { "location": "prepared", "filename": "world-systems/k2-174.json", "bytes": 686, - "sha256": "fbf0b4f5d126fb6a1b20fdde4a40ccd48ef0b09fce9beee774abbc01c2f23a2f" + "sha256": "f8ec91fc131d74dde742c273bf9211bb33e8e53505a5d17d45ceec204abeff51" }, { "location": "prepared", "filename": "world-systems/k2-179.json", "bytes": 688, - "sha256": "cd769f76b7770c61efddf1c21c24cfafc5146b13c8fdbec25e0ecc1e5596c990" + "sha256": "5bb87960aa6e5f01e3d85f5079da76696f441a1cec02b5cc3a3975f9308d58b8" }, { "location": "prepared", "filename": "world-systems/k2-18.json", "bytes": 690, - "sha256": "8721923f5db90c1f0fa41119304b2d5d1fdda67c69287c661c3df26fe8eddfa3" + "sha256": "d388dc5cd5297c8f2e136c519e75eedb0399349784d5bad5a20fedb4ba4450b7" }, { "location": "prepared", "filename": "world-systems/k2-182.json", "bytes": 687, - "sha256": "4c6ff47e0cfe5cdee75b111f43ead57ae328bf3812ec2a0b5a840f6db712d57f" + "sha256": "4184681083a844b9702ee206c504982b5d79e110521e4fa5bf2e7f343b3c67a0" }, { "location": "prepared", "filename": "world-systems/k2-190.json", "bytes": 693, - "sha256": "f829f842397171c8c11fc9082043f0d7c904325dcf37b85131c7a1e39bf7446d" + "sha256": "34c31b05980ea1c79d9a999e3a82187559336aeac003dfd7c054fc2e7fd6ba1f" }, { "location": "prepared", "filename": "world-systems/k2-198.json", "bytes": 1007, - "sha256": "770752fab085b6bece01ffe3d42a52de7f0bd67b688bd93d28b2860430a1ee45" + "sha256": "040a9cb76fbca885deb61ca67baf9579be43c0cafa2d18b7dbe7c04c3c743366" }, { "location": "prepared", "filename": "world-systems/k2-199.json", "bytes": 1006, - "sha256": "229547b7db3f6eeb851cf9553fa2d198121bf2ecfcb615584233bd234916d506" + "sha256": "13b92f1f86db181f018a8ae8c0fdbd64860fd7b064418dcab4520db1714508e7" }, { "location": "prepared", "filename": "world-systems/k2-201.json", "bytes": 1006, - "sha256": "be49e9a7f217f7bb712222474263cea18263dbd3074ebc197bb779743a0a340d" + "sha256": "07645263a073453afdb1193b7fb53347b837194328acb19af3b7b63a32a4b1ff" }, { "location": "prepared", "filename": "world-systems/k2-212.json", "bytes": 686, - "sha256": "44b4a58e44068284d239ac2244218400fb4efd88c1b4b32d5d544d6b636a7d2d" + "sha256": "d56e5e52646553bafbdc954eefb9c10e5e5874108f1ea5f32e13211136258e3e" }, { "location": "prepared", "filename": "world-systems/k2-216.json", "bytes": 683, - "sha256": "b9604a5cc3e12c4bf0e47380beb952c376c83d940d64c0e502c21f69137dc9a3" + "sha256": "3ea39a95821320d25ad7a43aa39e7c5be70b33f6d72bce225070f44a091a3ab9" }, { "location": "prepared", "filename": "world-systems/k2-222.json", "bytes": 684, - "sha256": "1f260eef187956be8eabde071ef9e8e3740c571249431f02e7f40a878e4e89e0" + "sha256": "560723e630b5a95e1832466bf05a435324d91d032366a5d5a573b2a7e898c909" }, { "location": "prepared", "filename": "world-systems/k2-228.json", "bytes": 688, - "sha256": "e0adb9027b64de10c4d3d9b5cdace14b3e9329de3b108aa5302d83e7298e0ac5" + "sha256": "fe512925e9356bbc7ddc902662f37913cf897337489f5bed0c7ad86f8b96386d" }, { "location": "prepared", "filename": "world-systems/k2-232.json", "bytes": 688, - "sha256": "d72daf6ba3f57ca6d6537320bca46e8ce30d5783b70a731ebe11c5839b4dd68e" + "sha256": "abd217fa457d617d1911bb2d44d440f6f9b02c968235b1b765536153e092206c" }, { "location": "prepared", "filename": "world-systems/k2-233.json", "bytes": 700, - "sha256": "4c12d10059fb8aa0f5cad923d2d1e64346e787b35763a9ad32e06d59fc414a8c" + "sha256": "75b4540452c66d4a09cb8d7e2cddd7ea28acc78c5a24dd979818dd0e4f5563d9" }, { "location": "prepared", "filename": "world-systems/k2-25.json", "bytes": 677, - "sha256": "64c9b85afcb9999e938d2ba31397fe56a75e4da9214d5f6a26b48eb4722aaafa" + "sha256": "42af9b004ed8be4edf552cd2a20a10e728b842e97211f7d8d593d7e72335175a" }, { "location": "prepared", "filename": "world-systems/k2-26.json", "bytes": 681, - "sha256": "299f7b9e2c219e159391aa1d873e1680a5bdd3c2d51eb4e153b70182d5660f98" + "sha256": "e04d4ef37cf3c8501f256256df50c6c30755b172c65be599f32713148cd2b3d9" }, { "location": "prepared", "filename": "world-systems/k2-265.json", "bytes": 691, - "sha256": "90a69f0b23930e2592416f8cd7898daeee0b5f63e1939ffd7acce604092f4ad6" + "sha256": "7367e77e6e58ca9e033a95fdbebe58d9594f4a6102f1c42c2ae441a7e9a97a61" }, { "location": "prepared", "filename": "world-systems/k2-266.json", "bytes": 992, - "sha256": "9725c9905310709441b1c39142c0949ed7ca6331826f32c8d80925e4e68a4e5f" + "sha256": "fa86ac83075285c862744629da5d7c5a178f263d860104bce930732193849f9e" }, { "location": "prepared", "filename": "world-systems/k2-275.json", "bytes": 1003, - "sha256": "d594b2d78fa9e2963524ad87337b079960da91421346a3b7f45541116520d2fd" + "sha256": "863a427f7fe98b5f5bda1140fc9f0589e2373d4be08d4454e5d78645999e6a67" }, { "location": "prepared", "filename": "world-systems/k2-277.json", "bytes": 691, - "sha256": "bf25f2d9052607d4d08ab1c2ddf2ab0b7aa0167fe3b8c4d6a3dfff17a0788347" + "sha256": "d7bc21dd5b4c3023b31bffdaf4c32895119ba53f1537dc42703448878b5bfd7b" }, { "location": "prepared", "filename": "world-systems/k2-28.json", "bytes": 681, - "sha256": "bca569d58c53caafee1075d28fe7afc89d6ee5d328653167b349ea50c7e23989" + "sha256": "5374b9d3da22816fdf252210fedde83d59e8de24d77c4b9e8d2ae257b23a40e7" }, { "location": "prepared", "filename": "world-systems/k2-284.json", "bytes": 685, - "sha256": "df0b14b5311f65ae29e345733439cbf39c197ec7db3afe5fd45f8ebbb0656496" + "sha256": "e2cb98189fc4b3ae3ff9a23830ee906cef19cc3b5ca53f3d0c6031875d5927ca" }, { "location": "prepared", "filename": "world-systems/k2-285.json", "bytes": 995, - "sha256": "5704bfc256804352c7e6053115b990acddc430b5615da7159d6cdec3389c11fb" + "sha256": "c1166fad9497b7790479f39a663a97566d6e983a2a1c09c015989530bab25ba3" }, { "location": "prepared", "filename": "world-systems/k2-29.json", "bytes": 681, - "sha256": "e28f2f375ceedfd1e07fa31dd5fbcca9a10ecfe4edfff2e53cc2f8e589453d5d" + "sha256": "334a80608346b51e736bc778b3ce5b93d4776cd71b4eaee0f673fcd37b995a65" }, { "location": "prepared", "filename": "world-systems/k2-3.json", "bytes": 976, - "sha256": "39b69dbbcac16a26469037c3a8737deb11ddc085659924cba4b6f5b9e4557900" + "sha256": "e54252bfa0b7da3d0d18891cec36af08162dbd63cc19968797cd43f54452e107" }, { "location": "prepared", "filename": "world-systems/k2-31.json", "bytes": 687, - "sha256": "a1a50fbd7eeed1cadc1a18e5b1b7cc634f1b8f9fb92d6cb925fe9abc3991389a" + "sha256": "2b3953ab10c230c7dd124bb72249ac7d05faef2db9bae159db4447ec187f8101" }, { "location": "prepared", "filename": "world-systems/k2-32.json", "bytes": 1000, - "sha256": "04e35207aabd3931fbcac042cf26e3808adbace2a5e674421e56b51681bda41e" + "sha256": "9f217147aab451d2797819b4f2ecc89b1988ed84cc7202fe9eb2c127cab243e6" }, { "location": "prepared", "filename": "world-systems/k2-321.json", "bytes": 685, - "sha256": "9dcc4fd081db84d3e7dec12506993842b446e46da846901a3978769c78b0b20e" + "sha256": "49533d4218faf850205347b0babc421569c0f5b125531980ff3e9bf14f8c22f4" }, { "location": "prepared", "filename": "world-systems/k2-33.json", "bytes": 687, - "sha256": "f3a5fcbe5f55c4558094c8901b4b1b4058de06935ec31297f614e11eb9db1a2c" + "sha256": "2d1a130ec17d3843721437bc6b6e7dbdb33e28ede9a9c05949a7d169d17cca7c" }, { "location": "prepared", "filename": "world-systems/k2-330.json", "bytes": 688, - "sha256": "75a17dbe2f40bc66c9ae41fd433cddbfef437a0bb20f106cab4840da948c71a8" + "sha256": "1b537506354890fecc19bc128368fcb94cd11cd7b218f79c53855734cb31b849" }, { "location": "prepared", "filename": "world-systems/k2-338.json", "bytes": 688, - "sha256": "42f99b88b0848ec7b07c78902a81a6aa53780520fc9bab555d2362ade5fc4762" + "sha256": "0ecfe24a293f3902626b9d84bb14edc8718bcfdafe06886be0d9d094703748b0" }, { "location": "prepared", "filename": "world-systems/k2-344.json", "bytes": 687, - "sha256": "1619a5141c82ede67adaaaf3b94d26433ba971bf3b83ab1dfbec938a2001a9f0" + "sha256": "786dc5dfc0547dbadef322a7218048ed91236463124daf40095c2d0bdc9c363f" }, { "location": "prepared", "filename": "world-systems/k2-346.json", "bytes": 690, - "sha256": "8ef16df804d0ef8cb7cbbde3539d76f86124a429add4017d4900cf3f93142006" + "sha256": "37d0e24c36efccbc789bb656cab63b4fa2dd0df28f3ae19637b0b0211a6f3b06" }, { "location": "prepared", "filename": "world-systems/k2-352.json", "bytes": 1004, - "sha256": "6e4eb35dd418aa982613b51f352643a6de55d2cd15a309c2b2567f197958f39f" + "sha256": "559e5cabb64ddd3fd15a936186fa35102f4dc5b308b494cacc34f43510a53119" }, { "location": "prepared", "filename": "world-systems/k2-369.json", "bytes": 690, - "sha256": "55149f3bb6e7665c99822da320f7bfe41efb3a3268b2635ac5377237559a5341" + "sha256": "8aae1fc925ec788f8a6fbc3df70100dbfe48986ee0c34c8e875a8b879166027a" }, { "location": "prepared", "filename": "world-systems/k2-370.json", "bytes": 687, - "sha256": "3a119e267dfc5a78ef5459e9f45ecd9d7bcaddce3307491604d74abaeb39a22b" + "sha256": "7c1cb660628c8e7a7cdb0ef4065daf75bf11f8080b66c8b636146ab0e70623bc" }, { "location": "prepared", "filename": "world-systems/k2-376.json", "bytes": 689, - "sha256": "025ce241c327bf830211f4111996999dd22c438174ccb67b0b42359ff2b7a1f5" + "sha256": "93e9ef41fea76caada59b0905743538769d3c61a5f1e60f0484bdd13ce08541a" }, { "location": "prepared", "filename": "world-systems/k2-406.json", "bytes": 694, - "sha256": "6553deea73b865285a10b43dad76c78a6c616088999195ced2614ca61ace5da9" + "sha256": "3e02747f6090c5e12b1263a1120eaa1e1d566766bf1c9aa5ee4b004e2360bff1" }, { "location": "prepared", "filename": "world-systems/k2-415.json", "bytes": 682, - "sha256": "7ec88a604ba394d65bd158a9f95dd437913591acb8337d4dea673066db097549" + "sha256": "4eb971bcbd799f081ef267631106031164121b13eefa3ebfd04a3cf3558ff6f8" }, { "location": "prepared", "filename": "world-systems/k2-417.json", "bytes": 687, - "sha256": "0ef66aa84c3b26b1c38c80b316ad926b79f0de822b505a57d326dd4bb2536c22" + "sha256": "b470b2e67403c991d07761a91cf22e82c1c4ebc9b5bb20453626195a6b21283f" }, { "location": "prepared", "filename": "world-systems/k2-43.json", "bytes": 683, - "sha256": "dce9143e1afd5ef85bcfaa2fc8dc982ef0e01c8e6b4d7910f956d129fe072db8" + "sha256": "6f8287c7c75b78e63bf2040c70ce9d41ddacc29b69dc25faaeb62a5bde4f3d69" }, { "location": "prepared", "filename": "world-systems/k2-55.json", "bytes": 685, - "sha256": "78ad3772c4437b0025ff9c17534c78b790514c38a33db4a64f7287a1a75d4bca" + "sha256": "8dd409ee2ae3d6fcc151f47f5d0ed31ced12fc5f329c82439760c6c74d2fd88b" }, { "location": "prepared", "filename": "world-systems/k2-58.json", "bytes": 997, - "sha256": "609fdb0354d5c125dd4f3e4283163f5959485b6705888bfd85a7c84d293695b1" + "sha256": "d6865cf3516bc960e040a31008675db8ca79b9ae2f742281fc2cd57ee570f68e" }, { "location": "prepared", "filename": "world-systems/k2-65.json", "bytes": 682, - "sha256": "c19e9c4078d8a30c6c2594f72a9e5f791ed4d2886cd70ba739ce8473ceb16beb" + "sha256": "56600dd630a045090b8aa797a5c416c814b818cde21973ecaa5ca6f0f862791a" }, { "location": "prepared", "filename": "world-systems/k2-71.json", "bytes": 683, - "sha256": "ed5ea13e991356cb7ed219ab26be8301f2643e5ce7563d167c30e5c27b786681" + "sha256": "1c28bedfc38eb30077cdd1201093f28085121d056b33cdeff8bc335db0968267" }, { "location": "prepared", "filename": "world-systems/k2-72.json", "bytes": 681, - "sha256": "180ad60d4a4782ab7b4e9fafef3ea66569c4770dcb5ed8f39bd293139816dfd4" + "sha256": "4769f9c3f77b3ca69dbbee66ce795730e1e2118d4992ddd66286d471f5467f01" }, { "location": "prepared", "filename": "world-systems/k2-77.json", "bytes": 680, - "sha256": "e74ed132df399bf8912503f1583bde34ba9f05ab339f6e5293b1677e8b8c00e3" + "sha256": "22b9e865ffac644f79e064641582c4afdeebfe22786ab121a3140ff56ed9b485" }, { "location": "prepared", "filename": "world-systems/k2-83.json", "bytes": 681, - "sha256": "2f3addc0088f2b58671497c05a0a221556e92527884ec2f8f3b49501d93ca15c" + "sha256": "495816fe26d52965dba068c8e4396d1fa5519ab4c5dc93ff86b23954d5b074eb" }, { "location": "prepared", "filename": "world-systems/k2-85.json", "bytes": 678, - "sha256": "829131b5ae710300fce29c7fcf74b84ff2a3de021370ea09a761dc2df057efd2" + "sha256": "4b33ffe90cafad9b2929025223dc5549a19701e62b29effc855274342af7be4b" }, { "location": "prepared", "filename": "world-systems/k2-88.json", "bytes": 680, - "sha256": "1e4293bf2ec3f13bdfee2dbec0679166cef37dda43d61cff757e92f8dc7e8257" + "sha256": "94047f45e15826e4f8c22c191f6b358683f19059b0b506097da3e363f1b66137" }, { "location": "prepared", "filename": "world-systems/k2-90.json", "bytes": 682, - "sha256": "284ed08569ef456d4093b1fc004ba87d198060541019374e3770b5ccb6f6ec10" + "sha256": "f7601e5a03b7bbd140db26903680f114f5b7172ac87370a9b6e22c3be7c3da64" }, { "location": "prepared", "filename": "world-systems/k2-95.json", "bytes": 683, - "sha256": "72fe29c0643f8d27d58608e313221d69e1f2f57b8e910de18e94af55101309b5" + "sha256": "217317c04bb798ba761bd62315ce7edc0a9d70e673f743c5422be4866ac53aa7" }, { "location": "prepared", "filename": "world-systems/kelt-10.json", "bytes": 698, - "sha256": "cc4539917db9559cc82c2030399e786469390c2503fe300bf2eb97eadbbab9a3" + "sha256": "7bad6d05d4db63dcffa67a221ba652e7e6d8aecfe599f883bf0178811b0cdae6" }, { "location": "prepared", "filename": "world-systems/kelt-11.json", "bytes": 693, - "sha256": "4beb1c50e3f499cdb3e7084ae841b6041c7eda2ac6d01fdc754d262daa220517" + "sha256": "da8ee770d999d8a188faf9e4b86d9a600da281d325674d87056f08ce0b647686" }, { "location": "prepared", "filename": "world-systems/kelt-2-a.json", "bytes": 699, - "sha256": "759a6a2db3c411124447c890e0f2d0ebae47c66686e7f18511e7d7de7bd667e2" + "sha256": "2b9d662e5433078300d95d49ed5a665e461df3a78358d2a27ae93cd9b1b83f12" }, { "location": "prepared", "filename": "world-systems/kelt-20.json", "bytes": 696, - "sha256": "048c434562f7cfe346d4653d2ffaf23d26817d82fb313335bb408881e3282e99" + "sha256": "5a12bcdea69a5c9b669e4b1c99b97b1e8c89f4aba937fbc2eec2d84508fa9441" }, { "location": "prepared", "filename": "world-systems/kelt-23-a.json", "bytes": 707, - "sha256": "30b86ea624604184f9d3ddd114c8718b417b1ba3dcf3e1e4a9c304c0654088a1" + "sha256": "02dad09f8fa7ea2cae1ce9527cd3885bcc4923ed8295309f50ede5bd40d9cfcb" }, { "location": "prepared", "filename": "world-systems/kelt-24.json", "bytes": 691, - "sha256": "0a09f1187abb45e0cb3f90e0e2207f53bf73cb90920111b078c69d06e1379b4a" + "sha256": "83e64bfbbb825047c6708397a77ccea78abc55310c9b95da988e65c82d4f8fe7" }, { "location": "prepared", "filename": "world-systems/kelt-7.json", "bytes": 686, - "sha256": "1f5e49f2184a23ba4685100badea905259c90dd9cb509204e521d2eb763e1e49" + "sha256": "03fd94085c2e36ecc1f2d661d16b5518ca2b49920435b50393ecb46607263ed5" }, { "location": "prepared", "filename": "world-systems/kelt-8.json", "bytes": 690, - "sha256": "4fe31e3c2df90171e4feb6cc2517e05b61f6bd92c9a23147d4c251cf778387b5" + "sha256": "abb9756e887affeab75c810247a88d3bd18f0119a28c56840346a84c9ddc2eee" }, { "location": "prepared", "filename": "world-systems/kelt-9.json", "bytes": 714, - "sha256": "b34d68a4895040fb67ad9babcc07fd816718e066b7625e7bc20b07b13b2f48f7" + "sha256": "e7a22ba3f2e929f9cbbfa3ace4d54c983dcd0623c5c056c8d059b228abd93918" }, { "location": "prepared", "filename": "world-systems/kepler-10.json", "bytes": 704, - "sha256": "5cb4ab0197b09ada436ea8527fbf583f78e885aa20fe63b41d2062334d43cfe2" + "sha256": "cf93c57e692d62601c2f11af647f92962671a39b51a44c30d167d784f3b7d517" }, { "location": "prepared", "filename": "world-systems/kepler-102.json", "bytes": 712, - "sha256": "8f0f9459b5d724596ad2471984ea8c4b63e168068b05b6748bbffb447a0f045e" + "sha256": "fe39125250473ce91cb703fdc639526aca5ed8aefae637b84fc0a319a2b55b75" }, { "location": "prepared", "filename": "world-systems/kepler-1024.json", "bytes": 724, - "sha256": "53cce0ee0adb61d7caf551f35fbcbd60810912465587ab17cf0c129021d49f8b" + "sha256": "93e1ac8a3a1f5b346eb81d854447045b9ff569b685085d7df0479fe0bef22709" }, { "location": "prepared", "filename": "world-systems/kepler-1313.json", "bytes": 719, - "sha256": "f8c220c1555a7a724e460b95d7babc85e798a2ac37a52f0ab12704e202250e7e" + "sha256": "b405218b7d02bca592f649c2772039a1f7bfaaf9ded1012858ccf0966515000d" }, { "location": "prepared", "filename": "world-systems/kepler-138.json", "bytes": 711, - "sha256": "f099f9fadf22809e3068f5ac8c33bac7d63739a31dcb8ff84e15e8dc2d88a019" + "sha256": "250ed4f2bbc35698fd4346b0c9096d36c1f75fff400bb535b9844a9d0e689287" }, { "location": "prepared", "filename": "world-systems/kepler-1542.json", "bytes": 1714, - "sha256": "33b7d9547f1355bba841a6a4e6ec27c4019c2d2ab06216e1dd750774045a0071" + "sha256": "6bf95573f0f286e36613e2b31a52065e70378d16820d66262bba89c755b8073d" }, { "location": "prepared", "filename": "world-systems/kepler-16-a.json", "bytes": 1364, - "sha256": "94469f0fc9aea1d2de3ac265e966cd42fa0b3e5c395839988905bed67768daf4" + "sha256": "741f3912e0c89592c3535e2bf2bedb9749a405d95cfe2a11837921f9072a9e05" }, { "location": "prepared", "filename": "world-systems/kepler-1651.json", "bytes": 718, - "sha256": "5b6eebebe113b9dd40d73267a0438dc9b6ca8763042f3b54f6ac1570aaf0c280" + "sha256": "3b48b325079e10fefb26f39b5397af4c6832f870fe4c730c49b67f66aa8ab002" }, { "location": "prepared", "filename": "world-systems/kepler-1656.json", "bytes": 720, - "sha256": "e2f69236785c8f34c57bc21cc605bb0da0bc7c293c7fa8f731e214e1ac7e5f9c" + "sha256": "3280cb1e6ee1aeafdd600518bdf482469665461a311afa47f4d222d79730bd38" }, { "location": "prepared", "filename": "world-systems/kepler-1795.json", "bytes": 722, - "sha256": "032776ad3e6b19b2aaabfefe630e4672d3033facb3ba2926666ed6175d153f01" + "sha256": "33009bb55c2928eab441cf68cf5ad6cac05811c9324fc976b58bbdcfd09ddde8" }, { "location": "prepared", "filename": "world-systems/kepler-186.json", "bytes": 713, - "sha256": "638768bf2fd9404b8246276274abcb6ce4c9b86180f9290e8d306f8c5bda2ea3" + "sha256": "9fdd7f6ca625cabc794711fc173011e06aebdd2bb76b3dbf54b6e482762db13a" }, { "location": "prepared", "filename": "world-systems/kepler-22.json", "bytes": 709, - "sha256": "9ed56995fd6fda4df9b3327182bab76155b10efbf099340755373f367f4cb1f5" + "sha256": "2ef4adb9c125a1b4a7af4b1e767a782e2495127a52efefae8bd2e96dbd110bab" }, { "location": "prepared", "filename": "world-systems/kepler-37.json", "bytes": 706, - "sha256": "ce0e3118be71623e6b2799a8985deed2ad96f4149c48b7c15c6b68062e3347fd" + "sha256": "cdbb0f14f93c145058f807e42323f21b6ea6e84a87a192b7e03a94f393f46c62" }, { "location": "prepared", "filename": "world-systems/kepler-411.json", "bytes": 712, - "sha256": "045e1ddc496d306724c3093cb44b2bb4c40b506fc5fc26085c4cd7538d811add" + "sha256": "50663845f5334ff2f4e075f10b78b92e7972dcb8563f367bb9cbb26c0d5f439c" }, { "location": "prepared", "filename": "world-systems/kepler-42.json", "bytes": 700, - "sha256": "7e54ee50d72e586190d8c2d6686137a04dd4ec0cd445d3b611e215cb981960be" + "sha256": "53c370fb8e5f359a5a6aedce23143e8c5ff15220f38dd7704f05f3f82b4923a8" }, { "location": "prepared", "filename": "world-systems/kepler-444.json", "bytes": 1996, - "sha256": "580dbae8a5d559e10e4803dc5a0c3f5e52982ba4beda8c9de86104ddf817d916" + "sha256": "1809905884cd2f3caafb75bcbe4d7e222a42a066f243e417254cd9e72c56dc10" }, { "location": "prepared", "filename": "world-systems/kepler-452.json", "bytes": 719, - "sha256": "d1ea0cab95523affa06552182fa90c97afad5063f6079c6c09e857bc1c583991" + "sha256": "7143870cb68044c4621003ed62977f975518736241b6c24700137ce6712adc57" }, { "location": "prepared", "filename": "world-systems/kepler-538.json", "bytes": 714, - "sha256": "07f9792f3c922e135ffcacd71f77970ab03c45dbe0a712aa1663c3ab4acea025" + "sha256": "f7f0f1a881c6d1981f9ba5c8e83eab4cbf904f8b1da78433f758643610d06a38" }, { "location": "prepared", "filename": "world-systems/kepler-63.json", "bytes": 708, - "sha256": "94de4f26d36478ec04b2c3d9e58589cf91f928b34706529d6def5b7bbed4fdaa" + "sha256": "e4c1000a7a7ce3d019195c9421c9469dcfa44bef022049025c86191b88148f4f" }, { "location": "prepared", "filename": "world-systems/kepler-68.json", "bytes": 707, - "sha256": "abd5f1675ec7a6f6dea56ad2ea5137e82473a90918f72eb2de577fc3d9a00827" + "sha256": "cc11563d4c1e186e087fd1f7ed2ed47eb08cfccfc5ad3d6fcdb7abce7b791ecb" }, { "location": "prepared", "filename": "world-systems/kepler-7.json", "bytes": 706, - "sha256": "b0da889eecb2a827e1193f79f4b99d64788bdd0b678d9548a1dbaa12bc5bc5b6" + "sha256": "8f41fa867a3a2895949209f1c8215ed9d54ad78128d83611adb16d6f5b8e0dad" }, { "location": "prepared", "filename": "world-systems/kepler-732.json", "bytes": 712, - "sha256": "6b278ba6f6c8bb389b8f2027d29ca3ac806f23d0a48ee63fb7b0ed137121d79d" + "sha256": "45db0a1cfd47ee1feacd88256218ea763e43b4c9f95a65d308ca3a158f7ddd02" }, { "location": "prepared", "filename": "world-systems/kepler-736.json", "bytes": 717, - "sha256": "3fddcfc20ce6befedbcd4f79fd39035b0532f59641aa4701a3c4b6ba8b48b69d" + "sha256": "2a6f55c1d4be3f021808e008714d9a654d250853b72b696658ef12c5972d4103" }, { "location": "prepared", "filename": "world-systems/kepler-78.json", "bytes": 706, - "sha256": "0953876a30a9ab10833cfe7ac9d7a2c03cbb43d1e00b3137dc24b73fc1ac4818" + "sha256": "b2db57934ae69f89efe56b7374d2d5cb5075a91e439ace9b2152095317953637" }, { "location": "prepared", "filename": "world-systems/kepler-93.json", "bytes": 704, - "sha256": "3f86a7fa1764950d32cbcb1c98d0e3a0dfc4f422acfb8d4c7f882cf2ade44d29" + "sha256": "ad43bacdcd64ab2a6903864d3fe7cf4c43a12324e2266311d13436b9f93f549d" }, { "location": "prepared", "filename": "world-systems/kepler-94.json", "bytes": 705, - "sha256": "39f03abc9f7a6c51558a6d41c16947d4120f84fb4acf55c673f5d8ac1b257260" + "sha256": "5e5da0bf5eb1a005e0ffe8c0e7b408923b536e4698245483d5f3397db6809acf" }, { "location": "prepared", "filename": "world-systems/kepler-96.json", "bytes": 708, - "sha256": "dd632a334d337c6b5aca2ae00771cd992a2045e0465486debda7606416b96c10" + "sha256": "cbf6b6b78b4591db964d29f051d909ceff9b96c3e7d180293c12df842db53a55" }, { "location": "prepared", "filename": "world-systems/l-168-9.json", "bytes": 689, - "sha256": "0cd5fd20962686f91d3c8a03dd14f4c328a1fba427df59a31e203195396de9b9" + "sha256": "b6ed86a0faaeac139a389f281fc87c3b43b91f0d8929e8f984f0e3aa943ba451" }, { "location": "prepared", "filename": "world-systems/l-98-59.json", "bytes": 995, - "sha256": "33710e9704423777f63ea73d0b8fe6199e9121e6335162fcb2e754ab912a968a" + "sha256": "6322e127c399cc2dbc2f2f407e80cd8e9023195d9fdaf8212fbac604ae123bc3" }, { "location": "prepared", "filename": "world-systems/lhs-1140.json", "bytes": 1006, - "sha256": "6a8e0e3863b5724f36a218b248ad41742224a16a5bb9a7309868d07e33ba47c6" + "sha256": "2c9a94d853969637c0d01427d54b092b20488297d92f216df5ac39ec9c4c69bf" }, { "location": "prepared", "filename": "world-systems/lhs-1478.json", "bytes": 688, - "sha256": "72026b81a8495c9c281b37f5a62a91f24a0c5cd3b890e84497eea222c1a9bdd2" + "sha256": "a30ec1c0bf6cd7b2b1ec4af01b5a00c28ccdadcc8c45b6727ae54dcfc6837600" }, { "location": "prepared", "filename": "world-systems/lhs-1678.json", "bytes": 1322, - "sha256": "b7f6126cf84cae349e97ecbe5adbb526409a522c420bf6e2f5b119c6f570e0a6" + "sha256": "5d71d0606d22af3be1ac2c14e64a73f7eb895841325ca974b20624d783d841d3" }, { "location": "prepared", "filename": "world-systems/lhs-1815.json", "bytes": 692, - "sha256": "5fb7bc2016d66c2de964bdcbb471a8063a39d015710f347fe52ad656ef9108c5" + "sha256": "02f82990b7c2c580b8a2a79082c654ba4f9738aba1a078599c587e7b58564e3f" }, { "location": "prepared", "filename": "world-systems/lhs-1903.json", "bytes": 1641, - "sha256": "315201f12cc8a7b7ee4fb92a5a7d96773011b6d7afea9b441424c4a6b07d2fc9" + "sha256": "e40e890b677556ed169b0da836d4008d3fc2c5dfef259e6f7d67a0adf69e38f9" }, { "location": "prepared", "filename": "world-systems/lhs-3844.json", "bytes": 703, - "sha256": "1ce229cadf188db75b3eaf52afc7971d8e995aa0cd8e40b4127487a6e1211e99" + "sha256": "d34ed9e12accb00d3ab97db9219c7b321835a1296db14791de6e9681ccae9a22" }, { "location": "prepared", "filename": "world-systems/lhs-475.json", "bytes": 686, - "sha256": "a8ecfaf1f62915a2a05dc47d92848704c4b5b296aedadecd03400c40103f8fdc" + "sha256": "39c22d71e0cae31ab4504a0810a10c0aefd2dacb6c7aa3b6f35eb2107c7301a8" }, { "location": "prepared", "filename": "world-systems/lp-714-47.json", "bytes": 703, - "sha256": "df3059a387b6bc55091ff3ed9784450ac023d7489eaa8ec78b0d9a108cc42ceb" + "sha256": "76a982e57c2286b8bc9f3ded9360a534cb88893d9efa861a31e45b9788b42d6e" }, { "location": "prepared", "filename": "world-systems/lp-791-18.json", "bytes": 1343, - "sha256": "4e195f2ebb7085146d0bbf4228722cf7a0d8f9dfbbaa5087ef99b5c28b8bb219" + "sha256": "a25b09787774bb727b0bf6d0ff2dcdd0c7b37e4d3e5b19ab7ef2eb798ddf5b3c" }, { "location": "prepared", "filename": "world-systems/lp-890-9.json", "bytes": 694, - "sha256": "7694522ab6242e471bcd7569ac8ab3c0e741d91d212d0d6ec022064c7f1be08b" + "sha256": "33e49a8a39fbe2a78682b3d42d7967dbe55f9f7790e42bfbe802f9bfdc456f40" }, { "location": "prepared", "filename": "world-systems/ltt-1445-a.json", "bytes": 1026, - "sha256": "c27b55c174fc874e491db32a92f2a3670934d7e59035876faa05deb6ea574d81" + "sha256": "6c41bb22084089fe0d8311d9491f493412c7012f47a619b8f41ff5504f33c928" }, { "location": "prepared", "filename": "world-systems/ltt-3780.json", "bytes": 1014, - "sha256": "e1a0a4dfaf5c2d7454826066803151099d58e9fb3a12b164336c6893d876f430" + "sha256": "4a1033cedaa1473962eef0a9197bbaffe80cc6479ecc6155635006b6dc202a23" }, { "location": "prepared", "filename": "world-systems/ltt-9779.json", "bytes": 699, - "sha256": "c902f97d93cc7a6903ec727a7bd549dd8afd2a166a69f7ffebf4b43ee910200f" + "sha256": "821c26ec1a3a465dd6c4c63c73ef09ffe7e56f110fd14e8dafd3b3b9176c7566" }, { "location": "prepared", "filename": "world-systems/luhman-16.json", "bytes": 864, - "sha256": "5326f1f2f18d46d85e3ae6b5fc715e155cc711d613573fe707903445c2b3d022" + "sha256": "9229c0d73865603d41ab78f521dea6fa91e7e512ea9f73ce23d84e10292ef750" }, { "location": "prepared", "filename": "world-systems/m31v-j00443610-4129194-a.json", "bytes": 1003, - "sha256": "54f97ac7fab7e0ba26091a2ad598300ec7e45a3ad485a97d82ca8c9da0ff1994" + "sha256": "b872a13c97898244d8b29330a808ad3607d49de0209f33f62360a58ee396b2ce" }, { "location": "prepared", "filename": "world-systems/m31v-j00443799-4129236-a.json", "bytes": 1001, - "sha256": "494802228d7b095f9b3cbe785bbe8e5d1c11bfa7672f50c21fc334ae9718af5f" + "sha256": "e5c49c5938041a3256ef028ec9cb207e42e7d6b8ee0613b790dc2edb817aa36c" }, { "location": "prepared", "filename": "world-systems/m4-v66-a.json", "bytes": 920, - "sha256": "a1b7cf2be0b61bf205a68796e38b982d5da4653b8fd756128b47a99d0c806a71" + "sha256": "222507f4868c75215411e7cc4652d957ddbfe92f39a9803496081f79d6728a02" }, { "location": "prepared", "filename": "world-systems/m4-v69-a.json", "bytes": 920, - "sha256": "0f2166da58f5959df8664741816faa30feffc689f313285e4832c885d11f1e5e" + "sha256": "7919643aba1651fdbbc37b10b62a7d26368a6d4c04201b831122163a3a9db38d" }, { "location": "prepared", "filename": "world-systems/mascara-1.json", "bytes": 708, - "sha256": "148ae2dbf0f0fd8b35fce49cd84edeb3f627c5f54d675fa6316c10683f0f83a1" + "sha256": "8fc139d8751ed7f2757283c748f8c0c56c358c79b2bae7156df99006d20436a0" }, { "location": "prepared", "filename": "world-systems/mascara-4.json", "bytes": 710, - "sha256": "a4d5d93c71ee92ea34ae4ebdc95f62053131b9aaca4a20d7fe64ac1a4508722d" + "sha256": "65ba94989fbce452dcaaa4b31a15daccaa702e1296d062894d63c7d5c046de3f" }, { "location": "prepared", "filename": "world-systems/ngc-6362-v40-a.json", "bytes": 959, - "sha256": "41b589cf420ee70b13fbe84543c28e3d71903cffb26ab51a23534640a90170ee" + "sha256": "92df7a18ec705d575ac41dcccd63fe1f19b85588f3b231c8bda94666b18f0565" }, { "location": "prepared", "filename": "world-systems/ngc-6362-v41-a.json", "bytes": 959, - "sha256": "f4df7220291405b44d5b2f89b65e9ec8d57ab838171c7caaecce3dd65af674a5" + "sha256": "ab7bfb9989713bacebab905bbe76ff8dbf640a99759947992cea61c6db1dc48b" }, { "location": "prepared", "filename": "world-systems/ngts-11.json", "bytes": 696, - "sha256": "99c55e7ab4709de473d62b6dd1045313500af1d6aa436ad5cac848a31a699ce4" + "sha256": "980d1d92e5ecd57d9110e3165b9a5392fe473889996c4cf49417a78748c373e0" }, { "location": "prepared", "filename": "world-systems/ngts-35.json", "bytes": 696, - "sha256": "60507b351e6436cc0e2fdde3d8f31110b2fdf94c3155861a7ad829431dbec269" + "sha256": "314b71ade7537c13f3b448abd86f23c826a4594683af46407bbb7e7aff3e13c0" }, { "location": "prepared", "filename": "world-systems/ogle-lmc-ecl-09114-a.json", "bytes": 1002, - "sha256": "12c410181d301edff54636d206bed18ff5325f146f572b654bfc225545a6d86f" + "sha256": "90a9df6f4a9ad6da0c25bf32a5261d0ee93496a5f1c70ce801aae3d0ad41d19f" }, { "location": "prepared", "filename": "world-systems/ogle-lmc-ecl-10567-a.json", "bytes": 1001, - "sha256": "67d17db5a2c706fc7b3758386aec039f50372ac2bd863727472443c686b31a10" + "sha256": "460f148d658cc78a69402c67eec90ff8afa8aced81177e27e45d0f78ce041859" }, { "location": "prepared", "filename": "world-systems/ogle-smc-ecl-1421-a.json", "bytes": 995, - "sha256": "adc86f22e7f50dd349f8cd79424b031e437666423c9b6df1aa220e0a716957cf" + "sha256": "2e17021b084358b4b9d16b000c0b2c135eaed996d0c4413adf45ca01a8daa040" }, { "location": "prepared", "filename": "world-systems/pds-70.json", "bytes": 1022, - "sha256": "07e28481ebbefcfeaa1bc8fa9335470ef3c33e8c6ff942be8d9c795941dc683f" + "sha256": "eb129187e4f82e55c9dfec0e559226c66761267e9a80b66d496c67483c3bfd1c" }, { "location": "prepared", "filename": "world-systems/qatar-1.json", "bytes": 695, - "sha256": "5a142fca83a27eb79e67363127bfc57ff8730c73edaadac8b526bda77ead540c" + "sha256": "fe81a2116c89a2f1c5fac20d11b7e2988543d57ea48c62bf48badcac24c49641" }, { "location": "prepared", "filename": "world-systems/qatar-2.json", "bytes": 697, - "sha256": "7221856fce8fc125c28b94b10f7a6f99360a77b042617c836613831ceb052ca3" + "sha256": "6d60663c184d2e190f6c13c31eebf3db65b80d7318ddc3922816f7c2d5f70d85" }, { "location": "prepared", "filename": "world-systems/qatar-6.json", "bytes": 697, - "sha256": "a30b778c5bf858c57ef8516b590f1bfdea296488ee03ef4c200116c9a7125e41" + "sha256": "7334970111485c7d32000926517db81f0009532e6b70fa6d33ef2c518a4d8381" }, { "location": "prepared", "filename": "world-systems/ross-176.json", "bytes": 697, - "sha256": "e7d66c1b436df26e1960e2a3a893c47d830ea468f929c2a669baa50b0eb5b90a" + "sha256": "4f8d429790080ecdc34fd07b9d185030f005d55610d58e1f8f6e115c8d971051" }, { "location": "prepared", "filename": "world-systems/roxs-42b.json", "bytes": 1347, - "sha256": "35a9838d540ea258374dfde0c1991516df988071ababac299eb787873fa1a9bd" + "sha256": "1de81f16bcfd74011eea310fcea164817d8dc395ca065a552641051a8e3822f7" }, { "location": "prepared", "filename": "world-systems/sgr-a-star.json", "bytes": 18855, - "sha256": "c9a36c3467feb1e3ca804dbc8eceb4a51e3b85d3c7c26c6eb154102208e77938" + "sha256": "a79a60522ea17b506c60b4f75205190c175ce58549ba30da7fe93e41abca25bf" }, { "location": "prepared", "filename": "world-systems/tic-117642575.json", "bytes": 729, - "sha256": "e3c4443ccd4754e21bb59bbc1bac3dcf784270544f3841d13f8ece5bcd0335be" + "sha256": "94406623b2da02d89bd400fdf1097fc385df0c2ce285ac6ef0a7aad07eeb5127" }, { "location": "prepared", "filename": "world-systems/tic-139270665.json", "bytes": 732, - "sha256": "98610a7e8baf43097bd6865e243a0af0cd907388cfa3b0fb681b08e92c6b5b7f" + "sha256": "2b68f611eb6a2507b084a36aa96818285a255b072976ff575f5f120fdc013d01" }, { "location": "prepared", "filename": "world-systems/tic-139702105.json", "bytes": 730, - "sha256": "1781a6cc23463ec4d38fa140259a97f41c4f15e43817ed64931355182027a1da" + "sha256": "a826d65d338c873097d043c1df378dcd25c948de20d26ee596e86a819302f0ce" }, { "location": "prepared", "filename": "world-systems/tic-161045582.json", "bytes": 734, - "sha256": "97926a2066f36ecc24fbcb64756cc4a658aae0b36af18eb4282de6dc7a87b67f" + "sha256": "5897895ffd9c00c0cf28dfa0162646ba09affc828d3c17d7070b5514ecf61e97" }, { "location": "prepared", "filename": "world-systems/tic-178172313.json", "bytes": 731, - "sha256": "ab9bbf424a55e3d0b84bce706feb0eec222dafafa5e8a13943faa211cdfd16f8" + "sha256": "3b544e4e4f01350c84d36f7dc3f474fd21660f9cbc2b98e89c236b29b5641682" }, { "location": "prepared", "filename": "world-systems/tic-198190129.json", "bytes": 732, - "sha256": "a66ca8f2f5397195dfca282cdfc7a5a300eb23f4751029b0de1f950dc8742972" + "sha256": "4aa5026a836e002b28f08a77c55508a1f0fec260f0d7b12af3b1049b3bfe9f15" }, { "location": "prepared", "filename": "world-systems/tic-231949697.json", "bytes": 728, - "sha256": "1906ba5592cf4b7b20c3a96ac4b7743653a3b4e2ff48f2c2270b1fd656746cf8" + "sha256": "b0b26abde0b001f854114cb77e96297ab28e284182f078565cf5b5e9f3232f92" }, { "location": "prepared", "filename": "world-systems/tic-270471727.json", "bytes": 732, - "sha256": "5549e1c57d7fca114b574f11d150fa3bc3ca95e14b1caeada14255f2c400ebbd" + "sha256": "d780e687c49a1d353a079180da370e7053a77eb6e031cc07977013ee5240f54c" }, { "location": "prepared", "filename": "world-systems/tic-393818343.json", "bytes": 730, - "sha256": "b40dd5ebb400e238ddad1e21bdb40ee9aecea3edfeb9f84225cfb9ff651fe33b" + "sha256": "a0dd1d05d1b67b21b4f8e539b0c09345b8ac2af09be1bd1a8e80824f08cbc0bf" }, { "location": "prepared", "filename": "world-systems/tic-46432937.json", "bytes": 721, - "sha256": "3a00d4c3b1724ab8090bf58d088a80b11920d7dea0ded83d9802092ca41f9a6d" + "sha256": "fde0f5462e3a07a00f44bd92b79485463960fa6b32060d1f8f52c777abe00504" }, { "location": "prepared", "filename": "world-systems/toi-1011.json", "bytes": 698, - "sha256": "e29ac5f2f724a105f2b4919ed4f7c2bab42360dde2c394b7e3d45c71ef41ed7d" + "sha256": "c4fd2215fc88e9f9d742cb85a08e23ba683f4bc617c87391b4dffec6893922e9" }, { "location": "prepared", "filename": "world-systems/toi-1036.json", "bytes": 703, - "sha256": "83c6847fbbd14d2b0497a4644f01cb45bb700d66a5b660405b084f834f180e56" + "sha256": "7129058cf59ce8b0e61ed78dfe2a400a36334fe5c5b17bd0b0f7fad977b152bf" }, { "location": "prepared", "filename": "world-systems/toi-1052.json", "bytes": 700, - "sha256": "c5ac367a43ad048cc26428b14067e6295b8c317a6db1e08233b125df95024275" + "sha256": "06a4321abcf4e37687cef046b4edda6c8bc9e1ed28926d4df480c80349bc5a8d" }, { "location": "prepared", "filename": "world-systems/toi-1062.json", "bytes": 697, - "sha256": "09b7b09e680abaae4b4c36ba120ff35e865450bd8525a14a09661094f675651e" + "sha256": "ff22feccc51c583e4b9a3d8b4394a941e4096ea4a3441d0b4c44c70b19540489" }, { "location": "prepared", "filename": "world-systems/toi-1064.json", "bytes": 1033, - "sha256": "00e106a93d023c011493fd1b8de488c9544f07eeba7abc15cf5e94355a9e57d3" + "sha256": "6b9b2b1e5b16ea192b5a43793e074eec0624a9505e713a3877912cf331f0c5c4" }, { "location": "prepared", "filename": "world-systems/toi-1080.json", "bytes": 694, - "sha256": "f11c0246bd63cc357f61e02fedb276b486e597594bf84f7d05376adcbe66433b" + "sha256": "7208c51772db44eac1dc7adc428019f7450bcfd6f60ba7f6cfe9661fc0351f8b" }, { "location": "prepared", "filename": "world-systems/toi-1105.json", "bytes": 696, - "sha256": "ff4e5f68278150cf38cd3975d3870e7d6c6cceef8d9e946045d75faf5d9839cc" + "sha256": "a27ea2de575d46e32290b59c6aa6695fdcbc1c9b0778f8e2cee9ff54b3329910" }, { "location": "prepared", "filename": "world-systems/toi-1117.json", "bytes": 701, - "sha256": "916d6df543a9c127f74c4f093ce6fac81aa560b6fbe15b366a310f4f611cdcea" + "sha256": "19111f4bea1ff73deb1d66f8296b2a9866c782f322626c3a5dc5e323413e8db8" }, { "location": "prepared", "filename": "world-systems/toi-1135.json", "bytes": 698, - "sha256": "8e9c680ca1c93c3d5d41d6a8ee064ad5da427eeb775b021e9ffee07707722b0e" + "sha256": "d061b7ee791a466326e316c7dca758f3dae3d92ac58f59d16dab122e2b95c22d" }, { "location": "prepared", "filename": "world-systems/toi-1136.json", "bytes": 2311, - "sha256": "d8d4a324e0ac720aca3dd6d315cc81570ca7b83238d2e5280942c4ccab4d7502" + "sha256": "5170e67b2df37271c7db3818539e215cea5c57870ec5819031bf3dd066a9354b" }, { "location": "prepared", "filename": "world-systems/toi-1173.json", "bytes": 702, - "sha256": "434894f1c367e3e59c0d9b2765a3c0b8c545a8990357fb7df81677b83af7099f" + "sha256": "ec0e4e70b8d58010136770b8845c927839e6b41de679ff263fd3f5ee781b96c1" }, { "location": "prepared", "filename": "world-systems/toi-1174.json", "bytes": 700, - "sha256": "7b9dd15e3ff381e29a098cd7dc8c2e4178cea542708ddb5c6ca5fc3892a8c76c" + "sha256": "7b67be34c9500350c28375427ce341ced25f7b577c83a468bf27be396177fcbb" }, { "location": "prepared", "filename": "world-systems/toi-1180.json", "bytes": 700, - "sha256": "cc34615548eb98128d6b26b6a64fdad568c403cc271f27c1cab9f4d7e5910b65" + "sha256": "bc23a1bde3e313d3b736dfb842d6131b558547118b0176089b8b5d38e97f8a87" }, { "location": "prepared", "filename": "world-systems/toi-1184.json", "bytes": 695, - "sha256": "7c6d633a3a0f438dad9bc7dc27d2c99ae06ebc85beb5d14599bf1d8aac5b82fe" + "sha256": "5d24faeb676fa992ec79769551578bbf924e22cf67d596d5353dc8262e0b362a" }, { "location": "prepared", "filename": "world-systems/toi-1194.json", "bytes": 699, - "sha256": "b792e3213a4237afcb11e738e39a7889f16f90ba0d12e16b13395d5b7daf1a2c" + "sha256": "7e9222a3f7831dfbf796ecf36fa0420d85c5480fd885dca74cf8c8b2a270d87f" }, { "location": "prepared", "filename": "world-systems/toi-1201.json", "bytes": 695, - "sha256": "e3a0a30ac717b9af2cb9555e1fdbadf7ca2bb2c1efcacab054174144816eb519" + "sha256": "a50869087c96a4d578e606568dfb3d8f92efa37d3c51e7a56dd411b03749d76c" }, { "location": "prepared", "filename": "world-systems/toi-1203.json", "bytes": 702, - "sha256": "27b0bea0d51a7351ff84592283e7ceb764f3480f5a60c6a1e39d3b3dc981893d" + "sha256": "2139168cc09a8aa23b330d3ec207a03a24c1ed097b860927d4643d2a4d9932be" }, { "location": "prepared", "filename": "world-systems/toi-122.json", "bytes": 693, - "sha256": "66c5e131dfebd7071590c2943ad83b37f0d91347a3bf9fb1f31dcabd902585a1" + "sha256": "6a15333bd71cf4bf42a2f50816a074ed9f7aa0f77c0b2f647c1dbfef468564a2" }, { "location": "prepared", "filename": "world-systems/toi-1221.json", "bytes": 702, - "sha256": "1b2139d5d7a4efe762a2b55d0cd6fd5ed88a2e4bdc84f45c6011abb119103e4b" + "sha256": "c9b0833f1cc8f72ab6bba70616db431bd7ef3a5c484634124fb474bca1593728" }, { "location": "prepared", "filename": "world-systems/toi-1224.json", "bytes": 1015, - "sha256": "995e0cb8b75a34a652e29ce87b4dc6a84417dad99153974a4f6b5f7db1f56d9a" + "sha256": "a6b08c0f0ee1fcb550c0a825218406e2f1a45d0161cf290f7bd894edf6d3c691" }, { "location": "prepared", "filename": "world-systems/toi-1227.json", "bytes": 702, - "sha256": "ce0e805d75dbb5fbe64d5499bfb80892098d84665e726a76de59def9c30b4989" + "sha256": "a80140e673c3c861709c14bdc1f0120bc93849775e90d05cda4c041e02b0ae53" }, { "location": "prepared", "filename": "world-systems/toi-1231.json", "bytes": 698, - "sha256": "8043c6dea1e427fcff967a639d55adecc443b6a5aa78241ff163e8d120098070" + "sha256": "3da5ae6be58ee2c482f379c4b2dcd1a6700fb6767bfb65c173be3b8cf5558c47" }, { "location": "prepared", "filename": "world-systems/toi-1235.json", "bytes": 697, - "sha256": "ef3621b7637dba43f55bda992187e1cb41027de85cb5be353e6ffb348d0969a0" + "sha256": "91c3a15b6b64ef2eaf8d5b276d06dc1c6033d75bdff6b5e1dd2e520cc835351c" }, { "location": "prepared", "filename": "world-systems/toi-1238.json", "bytes": 699, - "sha256": "f74f7e385571e9ed1bde809bc83fdedc6464c9eb8485dfbfc9625dc8f81e091a" + "sha256": "3a595ac29a580aa48e128d4d9d50fee16c8de731924f65a7127d5dbf0784e496" }, { "location": "prepared", "filename": "world-systems/toi-1243.json", "bytes": 697, - "sha256": "530ec80e0f8ab1b36fca308a723688e13a58b8f034376e9a14a11a4256284512" + "sha256": "3f64220549d37008ed9122187606d24a388bc8348b71335533208edb16c6dfab" }, { "location": "prepared", "filename": "world-systems/toi-1244.json", "bytes": 701, - "sha256": "e1e67d17b0b5142cce8b6319d8b07c6e8c041859170adb532459254a4d45d4f9" + "sha256": "7e2638babce21755e4599c3745b15e13afb7372ef9c66c5b171049536cd732bf" }, { "location": "prepared", "filename": "world-systems/toi-1246.json", "bytes": 1666, - "sha256": "ad3b295faf6e76024c7d8c9041ddc13e6c5577675943c4055a028dc53d7c2618" + "sha256": "e575e842ba2c0e29eb210170d4a8a45f8b844e83728b61c852a3cd28ee285ba5" }, { "location": "prepared", "filename": "world-systems/toi-1248.json", "bytes": 701, - "sha256": "763bf2e942249e5abb565a5728761ad9abe31f56f44c5ac640ba07815bb926ce" + "sha256": "cb8bd06e8e43e8511662c16afa1714c686a0c48cac0bf0dc116de7956bc56710" }, { "location": "prepared", "filename": "world-systems/toi-1249.json", "bytes": 702, - "sha256": "5e4286448abcac563fb9212136c9a045756b232380033844e3b60beba08d9cbe" + "sha256": "6f8bb0641848244dc2280268524d861fcb04fa7ecf79eca8869cec0c5fe452f6" }, { "location": "prepared", "filename": "world-systems/toi-125.json", "bytes": 1009, - "sha256": "720262d2acdc6e281e66eb7ea0dbd79c0f8b6db4957fe39f64d2b8256a08b906" + "sha256": "0b8dc462043767760dd98a7155290b61e716296e4efb886cb4abfaac574f3051" }, { "location": "prepared", "filename": "world-systems/toi-1259-a.json", "bytes": 709, - "sha256": "2213ea16e9a81a2150db08f1e3f9ff53a6129fe57f3e87717c4d6f8d623751d2" + "sha256": "3e0f29a02314a6f8e03271a0ace30b47fa0d998bd68c08be8ccdb39a75de12ee" }, { "location": "prepared", "filename": "world-systems/toi-1260.json", "bytes": 1332, - "sha256": "cfcbbbf6d3b8699feac13d196d2cfceabe6d1bcda0c8f63e3bc656767c4f3c99" + "sha256": "89287412955c88bf1995666ec8ed1a7a82f440c93da23b27b52e30aec0e51022" }, { "location": "prepared", "filename": "world-systems/toi-1266.json", "bytes": 1020, - "sha256": "0f566dec98a7f3aeda7e090bef837d06e45a7458e6af7065c879099ad7855990" + "sha256": "63e040a1774252d4bd026ea07860203f8c27945f6c2bb11e741f49cb836b774b" }, { "location": "prepared", "filename": "world-systems/toi-1268.json", "bytes": 702, - "sha256": "663746f8c430aada973186767aafb996482a90144342c1546de2feb2af49d993" + "sha256": "0a0f0d28dbd53309d08dd7dad73061293c8e877d7ea2c01c7fdd148154a224da" }, { "location": "prepared", "filename": "world-systems/toi-1269.json", "bytes": 1023, - "sha256": "360a6a6b0717acaa2d8e4935260a36ada2e4d7b6a37e463e61155cb568ea14d8" + "sha256": "9aa33bdfc137cf361d645f6121ef9ef485f8202517b13ebb59013c808ef55c38" }, { "location": "prepared", "filename": "world-systems/toi-1272.json", "bytes": 701, - "sha256": "ba4508bc9ddbb9dba0887e7fd328c80d367d5fbb5461bd11b5bd81124b4bd875" + "sha256": "6f8a26f56afe264d34bf8e0d447f34db45f9d324980fd6f81391568faa040cdf" }, { "location": "prepared", "filename": "world-systems/toi-1273.json", "bytes": 703, - "sha256": "d1c2dae0023d0ecf567285baf8b3d9d6c040ec9acb61d7ed36fc743bd7c45701" + "sha256": "9aa5a756b5a22a85f7ae097654c6fcd48ef38993378b89b425c5077e5b3fbf23" }, { "location": "prepared", "filename": "world-systems/toi-1278.json", "bytes": 702, - "sha256": "359ac143f2c4bd9f30d06cb603f79969a1470a6d49749944624ee4040c0014f5" + "sha256": "f14dd0528560a2741ffac26e099adf964a30cec1d0fa805eb234d88c8fec7444" }, { "location": "prepared", "filename": "world-systems/toi-1279.json", "bytes": 702, - "sha256": "420b8388e7762c708b8c103b7797b41c0272b933a2503efdf47ac76e7d1fa55b" + "sha256": "26b21c0554c5d7e85fae75befaa3bd82cfe82ab26cfd0f4313f015de78c5cee2" }, { "location": "prepared", "filename": "world-systems/toi-128.json", "bytes": 689, - "sha256": "a1c18086c2fe45d1e65aa9181fc04a894aa2379324ea5167357614453788661a" + "sha256": "9b60b582b9d1607677a1e2ee916d99d502e9b6329d74d8086144c3ccd18e0e99" }, { "location": "prepared", "filename": "world-systems/toi-1288.json", "bytes": 699, - "sha256": "b50b8f5c2b044d193aa60a98e5198a91c44d81d7355a81fc665a7f96aa9ff200" + "sha256": "e1ac837c93eec2bc9d791abfaddd1518192957041c153f04477c1940ad5c3e92" }, { "location": "prepared", "filename": "world-systems/toi-1301.json", "bytes": 695, - "sha256": "44999f8c6dd5ef7cd2d6068c43ce5be7068cbea4615312dae1db49c2b140bd04" + "sha256": "24ba3cc3e011429a4e07f0d0c06fe00bb9735919bf3878aae55be44f5a6c9abe" }, { "location": "prepared", "filename": "world-systems/toi-132.json", "bytes": 697, - "sha256": "f589b8d867f0cb1267b3a6b62cff5092e935d6889dc85648434804848afbc642" + "sha256": "725e0db3a968115cc0872ac541e444c7b46e63a82d47d238c05d66d6e696e55e" }, { "location": "prepared", "filename": "world-systems/toi-1346.json", "bytes": 1022, - "sha256": "c13f2f3a13e3ecffb405131fcdc2c12b8fa0afa2a71be8f644958d97ef5860a1" + "sha256": "a4cb0d082d1de89f0ce0fefe21b9bf47a43ff2eb9398b21737fe176929273d86" }, { "location": "prepared", "filename": "world-systems/toi-1386.json", "bytes": 702, - "sha256": "082cbae531209f76e39b7fa3e5223ba44feee95e0c38479f2541fee3e44af43d" + "sha256": "56cd35bf5b165652ee1881c72e46409c9a173e418855a05500cb0740972ced45" }, { "location": "prepared", "filename": "world-systems/toi-1408.json", "bytes": 698, - "sha256": "118c1159491049193a6133bf07a804a065a52bf72a2a6481895f8bb8b03e7130" + "sha256": "b098ecd2ea0a362e83a2c3254c4eb5150069229f333783b7579170bfcba055a4" }, { "location": "prepared", "filename": "world-systems/toi-1410.json", "bytes": 699, - "sha256": "c50950d95d60bf8e10feaf24005a34b5f6c319c237de112f63b0651db5f44227" + "sha256": "0872b817725c138009ca4d2b4d7ef7b8a3af6bb9b70b7f24ca68e23dba7285bb" }, { "location": "prepared", "filename": "world-systems/toi-1411.json", "bytes": 696, - "sha256": "ae4f5a5c1fbbb9fabedbc7963415c2d09f7dc5c116a1fcb930577f3672b905b6" + "sha256": "293dbacea88f5c59460cc34e2b7cc77c508d585f6004fb179d4274bbdffaca7a" }, { "location": "prepared", "filename": "world-systems/toi-1416.json", "bytes": 701, - "sha256": "3e2b2615eeb915e0e367ad316dae88a259927ccde4207213916f38af05261cf6" + "sha256": "6d45ac8644917956d701adeabac8efdc2000feb96feaacc29d9d9f245460d4ad" }, { "location": "prepared", "filename": "world-systems/toi-1422.json", "bytes": 1020, - "sha256": "364bb38d03d22c1ed6cc3f23359f6f11a73b445da551e01ea2f92fd9422ea2df" + "sha256": "9a643148fcb118a37a59a2eee1a6a190d3d194172522b4822746919e189ef9e4" }, { "location": "prepared", "filename": "world-systems/toi-1431.json", "bytes": 702, - "sha256": "e212614d342d10c136e8fc2ed7b0583481fececc3b9ce187bb4f156f34a91488" + "sha256": "54906389d87ee8ed8050d68dfba41a44daaef112cac22c4ad260cd05e654372c" }, { "location": "prepared", "filename": "world-systems/toi-1437.json", "bytes": 702, - "sha256": "48ccec5cacb4137b4f2a1e6fc766dc8dc7a6a01aa9b34aa5e682cc77e70a24b0" + "sha256": "3ccb655a09463a08417e8391cfbf0ad9fdc7765535a6c56329c3de9f2e4f358b" }, { "location": "prepared", "filename": "world-systems/toi-1438.json", "bytes": 1015, - "sha256": "fcd17504a97779ccba877477f17b16b0ec0014662648b87eaab503ab23684210" + "sha256": "b4b648bc39ddaa8828c93bebd643d146c759cab81f4bed6754658ad1d466be74" }, { "location": "prepared", "filename": "world-systems/toi-1442.json", "bytes": 696, - "sha256": "f562ffb7738e26acd5850f8b25c8bdb51f579d3fefb847695ede3e197f6dcd5e" + "sha256": "e43c4081b29a780216ba8089b99418cf56a8a6868826de55d28f522ab328d9e8" }, { "location": "prepared", "filename": "world-systems/toi-1443.json", "bytes": 698, - "sha256": "165691adae420a19f73fd3e12907f045430c3d7a588b647610aaab52af3ab9e8" + "sha256": "272016b0eddfbcb31e52577554d9f5524d8ee38a9ce7cecf99a93a002107e0f0" }, { "location": "prepared", "filename": "world-systems/toi-1444.json", "bytes": 698, - "sha256": "d3ea953e32ffbf4b873b558d3c9825584399253bc2acde9ccedbafa136ff2577" + "sha256": "a06facfca7d0a8c22da467ce99117106ac4eab1162050e1745b30a31e721c62a" }, { "location": "prepared", "filename": "world-systems/toi-1448.json", "bytes": 697, - "sha256": "264af0b7f1398a5aa95904dc4b42b7cff86274c9347f54c8ba81b7de89f7bcd5" + "sha256": "ba10ce008f9cfbea1b4f31c19272e12d74549927a387fa6936499a333856ebce" }, { "location": "prepared", "filename": "world-systems/toi-1450-a.json", "bytes": 706, - "sha256": "58ae461db1b9ebe238b4162bd369e1ad62c53a9f70523d833cb0191a0e31ea04" + "sha256": "227919d2a8a52b54a62ff525c547852eb60839a07205330e7148124cf943dd4e" }, { "location": "prepared", "filename": "world-systems/toi-1451.json", "bytes": 702, - "sha256": "ed7f421d300923013c2dde0b1a633db60c5686411e7d92bd2b224c4cd533d6e3" + "sha256": "892becd20429af3a5034ff60f227c3671896d7faa93b9ba0944ffc165d618faf" }, { "location": "prepared", "filename": "world-systems/toi-1452.json", "bytes": 693, - "sha256": "aa6deae1e7e85a228f5d770df3aa6d8a7d50dbe71b3dc2037a77964e54dd6c47" + "sha256": "47a185738aabe76dd0d8e948195183d391b4ea27a0908d7975f4ecd7defb192f" }, { "location": "prepared", "filename": "world-systems/toi-1453.json", "bytes": 1021, - "sha256": "f512270a34b85a62c0950103b7e8bafc8435c2425d0ffa9fee109dc9225480a1" + "sha256": "1455532ed699b4f329a5fed4cfec2512fd358a7cb305b9c12c387adba7c71869" }, { "location": "prepared", "filename": "world-systems/toi-1466.json", "bytes": 699, - "sha256": "f5ec428fca3dff446cf0de2713a100e3edfc9a5fd502d74ddf140cd326c41b1e" + "sha256": "678dd5b5c10ae4f727e4865d00d8a0ecb70850957e9ce98a621120b6813682fe" }, { "location": "prepared", "filename": "world-systems/toi-1467.json", "bytes": 693, - "sha256": "548b07bfd6ef85eb1205b89fb7d590436b50c87c73b28346d7f86d6311f9ae0b" + "sha256": "29e875b27dfcb30efe69af7e070d635690206b4f08e6f17fc257a8269728ffc0" }, { "location": "prepared", "filename": "world-systems/toi-1468.json", "bytes": 1006, - "sha256": "a65f977ad47fc0780b21e8f0b658f350baee85e8d495e2d36c92c40df208df3b" + "sha256": "c32bf7651265a12a121f2a5872416a68ad7c709804ad60044e92d1cdad35da7c" }, { "location": "prepared", "filename": "world-systems/toi-1470.json", "bytes": 1011, - "sha256": "fe00d88ec107c5e3c54bcaa6eb45b9e87f67aeaf2aa6717fdae0d1d4f4ef229c" + "sha256": "04a90cb3fc8dc2a1781e4066deb2c3235731c648407bcb448623a3bf416c21b7" }, { "location": "prepared", "filename": "world-systems/toi-1472.json", "bytes": 1016, - "sha256": "048fce6af28d5c64980ff830016c54ba3cf49513179a0aa3e63ad12dd55b3719" + "sha256": "02f10399e564cef6c2e1791c4dbeb50c26f47f7459c924223019950afe639d72" }, { "location": "prepared", "filename": "world-systems/toi-1478.json", "bytes": 704, - "sha256": "0b5e2953a33e16a218d6e342058abfb70d6e64e091da1d1e863c59431257d020" + "sha256": "3c7d92b0c3caf9bce34b16ba59e6165bed75da9fe4654bffdb1bd42b6961ced0" }, { "location": "prepared", "filename": "world-systems/toi-1533.json", "bytes": 1019, - "sha256": "691dc53adbf01de71e98fa12a208c40e9fe5541decda410f6ad79960c295d9a8" + "sha256": "334da260ebe5bc0b78c8904671d4266f2ed4c6e7408e52427365a4b259b65d28" }, { "location": "prepared", "filename": "world-systems/toi-1630.json", "bytes": 696, - "sha256": "7258402f5c7cd013fc79dc804eb83e600c80067002ddc17c28135ed907ab405c" + "sha256": "be46583e6be76f14a6e0ab01b37ddad4d78671550a334225da9c7a63146bd47b" }, { "location": "prepared", "filename": "world-systems/toi-1634.json", "bytes": 693, - "sha256": "15ac08d043814a1e4e1c014adcb8de1305bf8f10f7746a1b7335390a19b4af0c" + "sha256": "752592b1e6b4dff4835478163ac4a33ab723f8660df5dca040584fbfcdffd050" }, { "location": "prepared", "filename": "world-systems/toi-1648.json", "bytes": 696, - "sha256": "69edfb936ec129f3e9a990ab035ac4f8ee59f488e8c60343a181318550d9f536" + "sha256": "50a7a73742d32737262b4b5c1a1f88a4f558f3f14e90cf77d739a63065fd0993" }, { "location": "prepared", "filename": "world-systems/toi-1659.json", "bytes": 695, - "sha256": "e84c057c0a16a8bfc0ec540964c5b3962dbc44d7c9701a1366331ae3d908822f" + "sha256": "86cad5eb9496ed1e0bcca6600ed2f81e47b5fd29d17c9f738599991805979480" }, { "location": "prepared", "filename": "world-systems/toi-1669.json", "bytes": 695, - "sha256": "4a3eccb73dfda755187c640720c034fa96d027074e798af0ee240a871cdc9e21" + "sha256": "bff4768d1d11efe37cf9ba78a821ec1fd1873a49a749048330144beba23cb07b" }, { "location": "prepared", "filename": "world-systems/toi-1670.json", "bytes": 1024, - "sha256": "f54632186920e75bd3fd560585dff04fd4dc6f7bd4e1844d036620e6703b9c4e" + "sha256": "a33041ff69b7e8e66cd97e7f6cfde0677ebe537f0e7e2e4c88971f0afbd6ea72" }, { "location": "prepared", "filename": "world-systems/toi-1680.json", "bytes": 696, - "sha256": "76331972ed765e386d12f434c54e2824de40e6321702996de0de678ca3624fa2" + "sha256": "79e0812134f0e492b71143f78148c50a911d0b5c75d9f77131541360b123a496" }, { "location": "prepared", "filename": "world-systems/toi-1683.json", "bytes": 695, - "sha256": "32cdda21754bdde7dee608a8f2189accfd7c4788397ee63183c78a8bbf2698e0" + "sha256": "e8ee27f8b83d02c5b116a3eb7bf3b1ca713a8fafeadf0fa5ab36d547543b8776" }, { "location": "prepared", "filename": "world-systems/toi-1685.json", "bytes": 692, - "sha256": "e0d3606e8c0d1fa5a587ccab9a68a7f90e780ca8634b2e5ef88e774f18bd4593" + "sha256": "71297b19051f3819b9380187764874ee8714a27f04f81aaac19b1ed6fc1533b5" }, { "location": "prepared", "filename": "world-systems/toi-1691.json", "bytes": 694, - "sha256": "ba0d638ab989852daf16060c06754b9f083cb64b01d0bb84e1f0355ba7e1e9a7" + "sha256": "e08333103327890fc9eef30f67f1b5debd1c6d97b045070b5e6f17d15bbe4aed" }, { "location": "prepared", "filename": "world-systems/toi-1693.json", "bytes": 692, - "sha256": "1e11841e0979b918837a4508df4161fadbfa226ff96ba20122d0e0788679d7d7" + "sha256": "8ffcc3b7386e14bdab159541b714355fb3457b050186287015ec5e066d615356" }, { "location": "prepared", "filename": "world-systems/toi-1694.json", "bytes": 699, - "sha256": "3f0a0c22ac734d56135b29ff02f0cd8eb43c82019ed75f26cdc1f0ce4b3a2160" + "sha256": "c690298d070859dec95acd7de465466389bdfa2e46cbf9098cf751d99912683a" }, { "location": "prepared", "filename": "world-systems/toi-1695.json", "bytes": 695, - "sha256": "adbf7ecaadb6549e2c1bbfb8609b3f0b75f3590f909188dbddac832efe5c60cd" + "sha256": "5daa6d278aadb67b4cbeff3e3200fda57de5c542a06d36c77ff78b086dc0a339" }, { "location": "prepared", "filename": "world-systems/toi-1696.json", "bytes": 697, - "sha256": "bd29703e5fccaa97292f5da4f842d0bbf905316003a65c52b987ce851d0cc288" + "sha256": "8cf76174188495105b1247e93c1da6ef849c84c55e736cf0b166910a0bd4c059" }, { "location": "prepared", "filename": "world-systems/toi-1710.json", "bytes": 696, - "sha256": "c87a5af399103188dd3259a02e25123e4c976732a0e2d511cf2b029e7e772185" + "sha256": "97181c7a076d0b83893f1cd72b29c296ab8caef95668e3852f65d96098153128" }, { "location": "prepared", "filename": "world-systems/toi-1716.json", "bytes": 700, - "sha256": "11bac6dbb6787cd974780e92d8b68686d5d6b9ddc2a4dc22bb2a4b321e75c716" + "sha256": "55b596bbcde890f2f7c31bf5cbfd5ef67d66c83ce07c0287131daaec7b38eacc" }, { "location": "prepared", "filename": "world-systems/toi-1718.json", "bytes": 697, - "sha256": "aaaaf9f7a01f15df1ae7bc62e7bcc58a33cf2658f63d4978d910edaa94c93669" + "sha256": "618e03b36940893851ca42486b1ab75b9a81fce201b8af81a2b57e74a2f02634" }, { "location": "prepared", "filename": "world-systems/toi-1722.json", "bytes": 700, - "sha256": "1938dc591e419c7d403732c31418c6f5b47c2e08ec3e741c9ba197c93369699a" + "sha256": "6a4f0bd9e5bb30e9f78640bf63fe8b4a090a688bab44ba333b2ea7018e714724" }, { "location": "prepared", "filename": "world-systems/toi-1723.json", "bytes": 700, - "sha256": "55fbe5d3a1d11adb3c91e4525a5ded9f69d0197ed7f930165d4fe0e219d94ee5" + "sha256": "cbef4e815867cca73bfbab7d26569c47435ade50f03aee0eb76adf7bd44fd955" }, { "location": "prepared", "filename": "world-systems/toi-1728.json", "bytes": 697, - "sha256": "faaefd32e01f86daaf8bb6438d4a600b33559d4cfb1ddab0c73a5ba26aa0bd40" + "sha256": "7cd102ff25e8364dbb184394a9fc1502d33f180ce2142a1c659e21292dae0282" }, { "location": "prepared", "filename": "world-systems/toi-1732.json", "bytes": 699, - "sha256": "837eeb435ba657c77277c472ef12bad4c0ff9359b57dd5dc4ad1a2c725addc5d" + "sha256": "8fd80df9242494e73d12629989db4f46056788279f748ad0756fd64d0cd9605e" }, { "location": "prepared", "filename": "world-systems/toi-1736.json", "bytes": 696, - "sha256": "a025518fd092bc3bdef1711fdb8f572316f88bebe2177b40f1d2ac3d24f4a500" + "sha256": "d33f3d9be6ab8202bd0c12bf63a189ce08e2972123dac24c46efab82415e26d9" }, { "location": "prepared", "filename": "world-systems/toi-1739.json", "bytes": 700, - "sha256": "16ba936ba180ca9bff0faa2a1d0f22c390fd33316f05e19512081a7079d0b8be" + "sha256": "d89ba4f8c7fcf3a3ab945f8534c10a6ae44490ab29adbfda8a8daef05a32ea58" }, { "location": "prepared", "filename": "world-systems/toi-1742.json", "bytes": 700, - "sha256": "f6153dfd3b02c68763f77e8ec2dda2cbe64e22eb8eaed2c9ff5b3ae005e17ecf" + "sha256": "675db1eb3b40507902777c0ec346d945b0a0baa7ca3e33bc3562dc230322e842" }, { "location": "prepared", "filename": "world-systems/toi-1743.json", "bytes": 697, - "sha256": "f1f272512698a8cdc4c0e800b64fff3f32029006007590337bc3d741fe289b32" + "sha256": "9dafae31bcb16c1fd4f294429b4aa7d8d8bfe4cb1ba64a3722cb9ddba4934b28" }, { "location": "prepared", "filename": "world-systems/toi-1744.json", "bytes": 700, - "sha256": "74eda8f99067e086cd2a2af4d1273a88163cfdfddd43be36a60d1280e7088e18" + "sha256": "b6eb071e7a01cfbcab91cbaeb7084b5f17d9dff114864fc6c89b5d8d9699dad0" }, { "location": "prepared", "filename": "world-systems/toi-1749.json", "bytes": 1019, - "sha256": "a59b23e7da341bfe8a8196f4901143d632f685dc750565a5bc7e5b836745ce17" + "sha256": "0cb16a25f169e70483b4a82c9bfe93d9b60e6d853e913602c8c141144b00dc02" }, { "location": "prepared", "filename": "world-systems/toi-1750.json", "bytes": 702, - "sha256": "dff8bf1d906408b11dfed1d980b2799b7694bdf31c334b8aee601fc2353e3eb7" + "sha256": "73bfd2796b57ed60b72965f9f0f0f03a39d4de5dcbcdf8c7d85dee874f558621" }, { "location": "prepared", "filename": "world-systems/toi-1751.json", "bytes": 702, - "sha256": "c7c2a1d783078200ff8fb2e419953155dbe6467e3d610aa19f291e0da7b700ab" + "sha256": "41e68cddc3e0fdedd02bbee9e5fe12f53490a95bce2ea4213186cdd16c10c0eb" }, { "location": "prepared", "filename": "world-systems/toi-1752.json", "bytes": 701, - "sha256": "e026642cc47afb6b3d8302fcbc8bda4a25eae28e5c8f9333613177707050bfe2" + "sha256": "fec2f3e71bcf3b24a797c980e1a5033512f16da2370526ea130b42235d0c4fdc" }, { "location": "prepared", "filename": "world-systems/toi-1756.json", "bytes": 697, - "sha256": "765b6550df67fbfa6b50ddaaccf1b48e29c96335fc47fe80e7d192667559bef5" + "sha256": "a4a5214def8b81928741eba86387dbd2e715e90d501d4d9642e6aa3e88782f41" }, { "location": "prepared", "filename": "world-systems/toi-1758.json", "bytes": 696, - "sha256": "ab0c087011350e56f1861a25810893894f929c1c762e8c7adf3a6813e4a635d5" + "sha256": "0453493bf4f0cb6ca16a4ed774b56f82ff8ae398374864219aeecabad66fd9da" }, { "location": "prepared", "filename": "world-systems/toi-1759.json", "bytes": 698, - "sha256": "8449ba1539ea22854406c3f51b6a107767856d9ac6614a71d3aa08cf7558e7dd" + "sha256": "26a12eda8c574e8a37906680a0bee32c576a2816297638ff9c7fcbb75d3bac59" }, { "location": "prepared", "filename": "world-systems/toi-1772.json", "bytes": 700, - "sha256": "d6ecd46cfc8a83c932fdc1a0403868d399dcaa64f7becef28d57fc2fdcf53ccb" + "sha256": "8c341905e97fbdbd480414ae743e15a72233bfe50cd5bd76420ebb06b071dbd3" }, { "location": "prepared", "filename": "world-systems/toi-1774.json", "bytes": 698, - "sha256": "ce5e868c101ec8a161aeb4fca1cec0fc02ef3fbc39b381f2e833c4dbb505374e" + "sha256": "6b897466b469fe0f946bed946fcc9c26bcbf6273b20842f3e1ce770ad09e295b" }, { "location": "prepared", "filename": "world-systems/toi-1775.json", "bytes": 702, - "sha256": "fa65a918c16d28dfceb4db10239a674b9440c7b97eeb1f45cd00b6123a52bc69" + "sha256": "fa0428b61d3064b9c20e5a7c10e40915f0797d36f96610623492ec66b7288f2f" }, { "location": "prepared", "filename": "world-systems/toi-1776.json", "bytes": 696, - "sha256": "d2e2e1c9483274679b2ada1b2843f7cf46e0b6a03de0b386deffc372600ef723" + "sha256": "163ccf464720e0b78b4a2ecbcdf4f2872cf13ef571699c6aead720d890d1dfbb" }, { "location": "prepared", "filename": "world-systems/toi-1777.json", "bytes": 700, - "sha256": "30ce05526a6068b4c6d24ab977451a51dceb82635140345f59127d055659d1c1" + "sha256": "f5b481c7bdfb7d5210c4f294e557657642a377ee623588613f02cd9c15ce9046" }, { "location": "prepared", "filename": "world-systems/toi-178.json", "bytes": 2258, - "sha256": "bfd9cd2664217be54c08ee8d5ec7fcb357b4c08016c7dacfe8959e4bc3bd6227" + "sha256": "204118705caea41fb1c116c88828935f5af85146aa24a0538d1bbab48dc98fa3" }, { "location": "prepared", "filename": "world-systems/toi-1782.json", "bytes": 1018, - "sha256": "f080de8d94bf46eb3e0591f665a16e3094af8c51b71e56bda48320795bebd1b0" + "sha256": "0340354f1055c6a395c7cbdc3190d4054738ad90cd3e1ada52f9f5f6801c4eea" }, { "location": "prepared", "filename": "world-systems/toi-1794.json", "bytes": 704, - "sha256": "013b21e926e2bb48dc3721f2271ba27848d182e2c11a5d8bc2db7aa8b889442b" + "sha256": "88f5f2871a40a4d26d36f7eff529a4c2075002196bb09627c3f1c41219e96897" }, { "location": "prepared", "filename": "world-systems/toi-1798.json", "bytes": 704, - "sha256": "609ea54118681cf202cea15d01e16d279a9ae18fd6e4d03ba38454dec3ac1ad5" + "sha256": "bc47ba3b64f5017984cc433827b4b338bd8f09a82e293c0230f19600705ac557" }, { "location": "prepared", "filename": "world-systems/toi-1799.json", "bytes": 700, - "sha256": "67acbb3de275400659b902340ff0f5511889824a9d488d6882ee5cbf9de065b0" + "sha256": "ef1260641235d135ac081d48a7b18ad3eee9789c26d361b09c03153c3587ed11" }, { "location": "prepared", "filename": "world-systems/toi-1801.json", "bytes": 694, - "sha256": "3c23b0437a28b3492ec5141e374b6060a3fbf0b241686cd948a8a3c66a43ab3e" + "sha256": "baf0408c56503f1af15dc21c9ea19544cffe9da50d728df7200ba8bccf63e8ad" }, { "location": "prepared", "filename": "world-systems/toi-1803.json", "bytes": 1019, - "sha256": "9f982e01bd10e419307f6c604a7305f6b7ea6c19ef4074e73dd642b34bbe9e4b" + "sha256": "3e559795da3cf9186cb90d24f86998d2b114308bee9add7ce65978214e4fb867" }, { "location": "prepared", "filename": "world-systems/toi-1806.json", "bytes": 698, - "sha256": "f5caa7fdb792246cc5640a257d2ed479c0ff9dedfd9d9caf9c7b4a922d581621" + "sha256": "6eb2b98aaa3535391b0dd61b90548ac340260d1327a11e6e3d0bb6a726f7a967" }, { "location": "prepared", "filename": "world-systems/toi-1807.json", "bytes": 696, - "sha256": "152f24167711a939990c2036b6960bf4a36942e32ddde6f1b7b7366f65e5691b" + "sha256": "8090468bc79f2a28cff4e976fda155c5194879df64b8ac1ca53b200280e85a18" }, { "location": "prepared", "filename": "world-systems/toi-181.json", "bytes": 695, - "sha256": "ff22bd836604ada114e9d996335726194b101d11aa5e976248563ca78a1e4a95" + "sha256": "29c56c76a06045afa313ba27a4575a325c8191a1c7937f1a6401250965a11dae" }, { "location": "prepared", "filename": "world-systems/toi-1811.json", "bytes": 701, - "sha256": "2c089ae964c286a2fb38e293b4c8246804c5351e975d72a2a6d32e706e4f132f" + "sha256": "6972312450b2eb8934fba5bdc658a9d06a1bc0dfaf92dca052a5972250e28d31" }, { "location": "prepared", "filename": "world-systems/toi-1823.json", "bytes": 700, - "sha256": "92a43b173b961b1c2f4af5a86c034a5c3674ff697bef02c5b6b1583c178243ce" + "sha256": "8b7f8aabd9ff48964e2feaec14958d1e89f4427d3aa838e521dd534a45d9ab09" }, { "location": "prepared", "filename": "world-systems/toi-1824.json", "bytes": 700, - "sha256": "29d96dfc731b628dd91e12c756350ffbded8e5fb644323a138da8ef455c4c050" + "sha256": "d2da09622fd51addd55d0f990bafe57766c243a145dab1733a0fcd855369139c" }, { "location": "prepared", "filename": "world-systems/toi-1836.json", "bytes": 1027, - "sha256": "938b0adc71f3167c964ae68c40399402f3ea28b0bbc5bb34148c4565eaf26def" + "sha256": "5eecc5e2429556eab149315b95fec93a760709a86977105eec990a7c7c17c746" }, { "location": "prepared", "filename": "world-systems/toi-1839.json", "bytes": 1022, - "sha256": "56d9722ccc82aa7abcb9724990e549da27603cfd9de89bd9961dc8a211c24c7d" + "sha256": "85ebc0ac9125afe5db65b783fccd3b2252cbfd9f47a430d1776f56c10488d2fc" }, { "location": "prepared", "filename": "world-systems/toi-1846.json", "bytes": 699, - "sha256": "f981f0cc116355b0e352cd7719f87c066cfea8b0f4e380e6b807c92b1650a64f" + "sha256": "5eb2ef07df231e1a0adf0f8e17b80f662d4968d49d1eb6c7623321ace71a0b8a" }, { "location": "prepared", "filename": "world-systems/toi-1855.json", "bytes": 704, - "sha256": "30589fd9630993577939f3851418978f03466585d273fddaf3e2a25d1e08383c" + "sha256": "c14fd9e7f0718d363b03364aea3e21c70d83d658e4ffdf8292d678eb36d5b7f8" }, { "location": "prepared", "filename": "world-systems/toi-1860.json", "bytes": 708, - "sha256": "c28438b84b42edc085c9d837282c982f5026f6c6fccdd1806c21a96038c9799b" + "sha256": "51a6828770548f0e03cac109d01cfd449d32f6ffafce2d7178011cffde71667d" }, { "location": "prepared", "filename": "world-systems/toi-1873.json", "bytes": 699, - "sha256": "005221c3431544fc7f45ae3d3e7ae6b582e2a7f73218652d2402e0c17fe1b34d" + "sha256": "5ec7844548d2d64201e2633da4101638c296e8ca49eb98a25cf8438f6b16411f" }, { "location": "prepared", "filename": "world-systems/toi-1883.json", "bytes": 701, - "sha256": "f5a7b864bcf05f4d0800c0dc4cb2f103d56b19f9a4145a49b3594da3d78d92ce" + "sha256": "485f2f21fa1e7306e7a6c92a2b3a6103392749886eb44f76bfc9e58fb9640dd8" }, { "location": "prepared", "filename": "world-systems/toi-1898.json", "bytes": 700, - "sha256": "61d180fbbb30e83aaf5356dc19f8fcc66e6051b00f1bc5fbb8e9eed19f28d4a6" + "sha256": "59ff3a48a1fb340a3117d0ded35dc745288217f1992d19bc46dc9027ba71c60f" }, { "location": "prepared", "filename": "world-systems/toi-1899.json", "bytes": 702, - "sha256": "26c16b52aff6e7439b052c0b4c2253af90ad09f02dd88345d2c3ad62a7f2aa77" + "sha256": "6ad85f1595017d3495a57339a7d26692a98851c257c2d0ae4b8ee59f9b6488e7" }, { "location": "prepared", "filename": "world-systems/toi-198.json", "bytes": 687, - "sha256": "ec3d7d3cb4a7b202798fe85ae01689e1b590e5be944db57275747cbd08870821" + "sha256": "ca72434b86e0b815a1e884447b49a1b13d1a4a8526b55d648dd2af20af3a7806" }, { "location": "prepared", "filename": "world-systems/toi-199.json", "bytes": 694, - "sha256": "2471910a6362cc463b3615bfd6871647ae955b6aa8e941198b7895f363e4c2e8" + "sha256": "b6953dd4a5a7cdc70abfc09d153ded0a993ea1fb7ea5c0e09eca81d820dc2ef8" }, { "location": "prepared", "filename": "world-systems/toi-2000.json", "bytes": 1027, - "sha256": "d1228aade8fa0309771a8f99223af260c54e37e2fb12f48f841cd6fbd1d0479a" + "sha256": "b1a2cc49ecb7a92a8bce81f11d39427fbf873cd8ee3fbae31672e96f19855cc9" }, { "location": "prepared", "filename": "world-systems/toi-201.json", "bytes": 692, - "sha256": "c79b2fbb79d6adaa7164e0b7a280d0e063ff0a4501d82159abafc667adef58d8" + "sha256": "97b724631112e949514a8989a99c6db2b9fe8039dbe42648d081e2cbbe7bc4f9" }, { "location": "prepared", "filename": "world-systems/toi-2010.json", "bytes": 700, - "sha256": "d477274ba422954ca2bef8c1157f1741b6defd43c85f773f2480eedb229dd1c5" + "sha256": "9913bdf137c64133ae698aa1955d3269e12738215b29296114589708e20673b8" }, { "location": "prepared", "filename": "world-systems/toi-2015.json", "bytes": 699, - "sha256": "b1158a16b4267bf4f1b1ea83d26fd5c08c40944543b6020618229bfdb343a705" + "sha256": "351b33ab03c1aa77fad09133da86b8ce1f39e353e893801e856e0ef4c7ea20ce" }, { "location": "prepared", "filename": "world-systems/toi-2018.json", "bytes": 697, - "sha256": "e823a8939e483814739072b896f053ed20fefbb5700da65dd5e902152493fe49" + "sha256": "c7abba353f9646b912d62f03f170d06991886db22c8c1c5161cee43fef4f9ba3" }, { "location": "prepared", "filename": "world-systems/toi-2040.json", "bytes": 708, - "sha256": "80127d0bddd21faa5bf43c75b397cfc703962d23599ffcbffa03c77cde2ef803" + "sha256": "35386e586307a283ba79ae7fdc701c555b6e0624876f825c9ac0d250fba7526c" }, { "location": "prepared", "filename": "world-systems/toi-2048.json", "bytes": 704, - "sha256": "0aa5e551b7ea7e0cd19b065f9d9eace3436e2abe694ff1787ea47af657c14874" + "sha256": "9012032d738354c485b05646f2d8928c1d867ab853e6d4e25125ec18a704fa48" }, { "location": "prepared", "filename": "world-systems/toi-2049.json", "bytes": 705, - "sha256": "3a5eff710f44854b3c584b6ca6bc21192fae814bf634279411fb68ddf4354b32" + "sha256": "8b76fc77876b3f67b0224b3fac671fcc64007c6cc89f6e89eba38d09ddb3191b" }, { "location": "prepared", "filename": "world-systems/toi-205.json", "bytes": 693, - "sha256": "094be8158e9b8c02c19895e108c5259147626b600bbe095e618cd5dc5202e7c7" + "sha256": "a54d2433479c25e9aa87a7a72f56f4b38df24e23c9ed617c51c1424cb3091195" }, { "location": "prepared", "filename": "world-systems/toi-206.json", "bytes": 690, - "sha256": "d294b9bb462fbba90f3f6f5d93b3f01ff27d76887fe627c49212d42e7100026c" + "sha256": "bc00a52ab517da3161679bf12d89746c9f6c11ef014c2f8e7073a08dcc211a1b" }, { "location": "prepared", "filename": "world-systems/toi-2068.json", "bytes": 697, - "sha256": "1192d94337b172853600e83a1848b4e8fe10e81d67619a43b03d205a0bc7dcb6" + "sha256": "00527fe86fcb822454d85ade6094d664ef5d3e456b7a2e53b3ef47ae57d4658a" }, { "location": "prepared", "filename": "world-systems/toi-2076.json", "bytes": 1334, - "sha256": "cef7732d64a975402e50becd5fd71933bc13663a4f58f193eb3e783618b65886" + "sha256": "b89c2f4306c57c5f7d84ced31f55daec36d5c2e9fb11a78b7df896bea9043454" }, { "location": "prepared", "filename": "world-systems/toi-2081.json", "bytes": 702, - "sha256": "ecc550a5456550d933dcef203f991ac8ba9d2e4555c1a9d63ae9ba372f4c5184" + "sha256": "e93ec9031733534843577895b353f85adfd2607109e2a6440ed94729edfc0ca8" }, { "location": "prepared", "filename": "world-systems/toi-2084.json", "bytes": 701, - "sha256": "55ad2f6095bbf398dc1bed4ce4c6417a8f969859ebe3b802a80a57f8fa783cb1" + "sha256": "354362f2484446249a167a3088294591b8764b3f9226c3bcd7e8fc40c1e87397" }, { "location": "prepared", "filename": "world-systems/toi-2088.json", "bytes": 700, - "sha256": "1f81ea40bd6650eefceb62c0d40ef4e92d3d85e4efbf45b27e241d0108dc6657" + "sha256": "4f4f2beaebfb385c044cc087e363c311b8aaaeb7a6a446207fa31e084ba10dd6" }, { "location": "prepared", "filename": "world-systems/toi-209.json", "bytes": 693, - "sha256": "ce33b2b4e778f02890d85c04f0cc22fa48bee0103dfb81371e2647530cc823d7" + "sha256": "61553d22a1de24ff73e93260a419ba45addd0fe5a61e499dcc861f3984555df8" }, { "location": "prepared", "filename": "world-systems/toi-2092.json", "bytes": 703, - "sha256": "e6e18f63928c28c47678617c6c46d3dfe8bad389d398235865fc8fd7fccbef71" + "sha256": "85f12663e3662590d50d81af5e291a8e1be3d08bb21e8bcb53885ef962728ed0" }, { "location": "prepared", "filename": "world-systems/toi-2093.json", "bytes": 698, - "sha256": "4ceb402a9ac69502ad968c09403cafe7da4089c6c1ed24a4d5a907d0f30c3e17" + "sha256": "e479759abf044ac6e5908987afe28194c3c29e2352f3dbc88f310becdb977c17" }, { "location": "prepared", "filename": "world-systems/toi-2094.json", "bytes": 700, - "sha256": "96d12f58744445dca6676e4a71f8aefeb3770381e518e0fb6e0613dde98d76d4" + "sha256": "a5e3237137875814f17bb66d3706f03bff96ec965aaecb9d6e55315b044620fe" }, { "location": "prepared", "filename": "world-systems/toi-2095.json", "bytes": 1010, - "sha256": "a2fb7580fb3e28c637af5f9e97003dac2aba677f650ea8cca9a6dcfc59f1ead0" + "sha256": "3e9a96cb5145e0e99d711b4b48f9c3d77a5fda94d4b58cd7576e42abe129620f" }, { "location": "prepared", "filename": "world-systems/toi-2096.json", "bytes": 1013, - "sha256": "430cda693d5b32d946a569f2fb0183148ec148d7bfb91c7dc894e0d8d171cebe" + "sha256": "165b0f61caf1e29ce8386539d79093982d51cbfd4427717a794995f4036d9478" }, { "location": "prepared", "filename": "world-systems/toi-2104.json", "bytes": 696, - "sha256": "846de112101efad5e3dcb3133a0d8f8f6b7b437fc5391d947c8a9e0f6ad3ef0a" + "sha256": "2b2972f7b54980ccc40c9a91df93df8b74f11a43e6dc2cf7299ecc40eb43c544" }, { "location": "prepared", "filename": "world-systems/toi-2120.json", "bytes": 693, - "sha256": "2bc2a9d10c110f7a8ccac710df1cde64c8de9ce1595deb7e23a07a92587417e5" + "sha256": "c477afb502767753f2a5bc7c5f7c8abd62c76561a1b8ee3d5700c93deb750a69" }, { "location": "prepared", "filename": "world-systems/toi-2128.json", "bytes": 698, - "sha256": "e6dcf10906a1200605602ab3884a485ad641b66fccc59698b60b24687ce5d5e0" + "sha256": "a4567ef8153371cd295de52206a1d3857c0351e82bdb58fb11700944a00bb55a" }, { "location": "prepared", "filename": "world-systems/toi-2133.json", "bytes": 701, - "sha256": "173bf638026a8075934666083795b7ca2781f0af9ed9c75581fb20f33892368a" + "sha256": "a85c235f48b17cb78c97e60a290896421869a607267d950f8139a87945c1a175" }, { "location": "prepared", "filename": "world-systems/toi-2134.json", "bytes": 1008, - "sha256": "a5c3700f130ae9dfe5962b15f86eb5000446e6efd48d2eb3b8a795c827559376" + "sha256": "307d69689544f0eb5e3b13ef08c54ccb2b63cb5fb034d6c41e37e6a23b01a500" }, { "location": "prepared", "filename": "world-systems/toi-2136.json", "bytes": 695, - "sha256": "16421acafae44c364acd980d6c0d486f69221662d572e45a7339700157f5262f" + "sha256": "c9332534716d06c56e4783242f925dac0fcadd141c7870f4e47bb983450089cc" }, { "location": "prepared", "filename": "world-systems/toi-2141.json", "bytes": 700, - "sha256": "3891ee4e98a7eb7446393b37147d4687c8fc887cb3111a6b193a600e023287bc" + "sha256": "4dc0e7d66bd1e7638a88724814d7de748ed44f92643e8bc5f270dbbf84694bf0" }, { "location": "prepared", "filename": "world-systems/toi-2158.json", "bytes": 700, - "sha256": "74c427b081900a4d8655a6e0f65db3cf38cc64a6f08cc329761e45346d76cbc0" + "sha256": "2d5dcea617d8565f879fdd47ccc41795ff148581c18fd32d7ec9eed051317cb5" }, { "location": "prepared", "filename": "world-systems/toi-2194.json", "bytes": 698, - "sha256": "06a50d525fc107cca8ff8b26ff023c3fec513e74836c787a41e678134df6ef72" + "sha256": "64d6a2ab3f545774bc10a88413daf52ac7cab79ca7fb09fa3b9d38a45e5c8cf2" }, { "location": "prepared", "filename": "world-systems/toi-220.json", "bytes": 694, - "sha256": "035c8efac89e66f4922e358bd6880d9f5bd9bcda82eea8746c36d0acebff189b" + "sha256": "27a52136a500483bedeae114526c1080c981f096687170433e336e79831f0033" }, { "location": "prepared", "filename": "world-systems/toi-2211.json", "bytes": 702, - "sha256": "056058c29dfdfffe19a7183dfaa6d5ebf4739ccabe7a260a6119f6395bfbbbcc" + "sha256": "9a586c76d4dfa9fa6d65d81e7228edda3eb908efaffb6f75d7fb4ebfaef9d663" }, { "location": "prepared", "filename": "world-systems/toi-2227.json", "bytes": 701, - "sha256": "3e87c46aa293a812cb15370b8a15c6b1911913317c6c64745e12c5b94f3a2cfd" + "sha256": "465ae30836574c4f438b9b289357c2eac803492fcb49d3779652e91716e4a51e" }, { "location": "prepared", "filename": "world-systems/toi-2257.json", "bytes": 698, - "sha256": "1aea64b596484160e68432aeae507f25ee02485781336a391d6e4d59e2816f43" + "sha256": "c0e6a23b8bc3ddf8bba1792c21dc605405149c6b0567c9cb1b55f651832c6e78" }, { "location": "prepared", "filename": "world-systems/toi-2260.json", "bytes": 701, - "sha256": "7257bbd34ad29c7d43c5b27cc82535412b1e3f3885fe247d529a0667edff1bf6" + "sha256": "8670c68f43dc3bb23eb810761ba58e0434dc937629e081871bc09735c4db76d2" }, { "location": "prepared", "filename": "world-systems/toi-2266.json", "bytes": 698, - "sha256": "fc230adec5488ec7876d06f9c0cc1b3a6e3f0be9989e09e6a17f8ad58dc0dd64" + "sha256": "536d0f63ed2ea20a636d3229a9af5e05988c5ed4ff45b8ea7596fa01c00bcd71" }, { "location": "prepared", "filename": "world-systems/toi-2267.json", "bytes": 996, - "sha256": "ec9c50623af2b2d8c11116d51b20f08951057b767f96aea4a1ad1f5ba8575300" + "sha256": "3f72300cd78b2152eb77dd513bf19230f4bd102a7331005f23fe1ec90a61e368" }, { "location": "prepared", "filename": "world-systems/toi-2274.json", "bytes": 697, - "sha256": "b262cddad76c56bf4d4e80e9687d9c213902c3fb1a33590cb13dec4cf1ebf627" + "sha256": "cb4548bb868ef084560949587e3be42d16aa4801b9497d3306768d027bf5b19f" }, { "location": "prepared", "filename": "world-systems/toi-2285.json", "bytes": 698, - "sha256": "7fe6b9b3ee2594f524f9d709e3575f6368ff854b2b955b121be5603f3176bdb9" + "sha256": "61febcfc41d2a54e2fc2e0407465900f17131e63a335e27bb0c38015df2c1a11" }, { "location": "prepared", "filename": "world-systems/toi-2295.json", "bytes": 701, - "sha256": "a8017c7b078891dd48ef6bbddb2f91eb5db24bf685a6a7700aa8f0b5bd34dca5" + "sha256": "9b9f492e2501618dc0f09c4f157d8c9ff9eaccb987f0eda98b1a73d41ef80d5c" }, { "location": "prepared", "filename": "world-systems/toi-2345.json", "bytes": 1022, - "sha256": "d099156f839555412a8589b841505598f70a2d4e0be3ef847d865d89d6d8d28c" + "sha256": "c615339127c09ac9c38c5820ff7072fd6442ba71f13df774f3d65513a27df9db" }, { "location": "prepared", "filename": "world-systems/toi-237.json", "bytes": 1006, - "sha256": "c4d106c87145732fdd377d03dfb56db0e31d12bb95247b78d74a7df841d81f27" + "sha256": "3ec39336839b0e4c60821aee04b6e961810082be9d68411c05ead2e8caeec3e8" }, { "location": "prepared", "filename": "world-systems/toi-2374.json", "bytes": 703, - "sha256": "b0ae8afdd3e6b58c5886cf358fe6d2822751a0541afe2a9b8531bd0d80e77903" + "sha256": "b1f5452eca54f3c136e190cec38ae48714e3d0e3ab0ed6f1228fe888cbd283a8" }, { "location": "prepared", "filename": "world-systems/toi-238.json", "bytes": 692, - "sha256": "3b372456a6e59525e1e39ea4a2bb11bd6c93bcf50e9cb7109ed56427a092fcf7" + "sha256": "79e2717cfbaaf6669782f37a27276978f8011b43c8da743795f6d726f75a87ce" }, { "location": "prepared", "filename": "world-systems/toi-2384.json", "bytes": 701, - "sha256": "547876a06146ef22ecd78b66232c2ce03094a08747c1dccd6343ec25204159e8" + "sha256": "fc00ef50bf8cb7fc45086a8bd150ea4c9287ca51c3f2187d62949497ce318542" }, { "location": "prepared", "filename": "world-systems/toi-2406.json", "bytes": 697, - "sha256": "b1f5b46af59f3e6f518a03572a7c769f61aab07e08243b6eaca9d6ae8bc9198a" + "sha256": "b2ae875636ef9c4bc6c4b8ff684dbcb8f799e906981ed3d49bfc2efddeffa9b6" }, { "location": "prepared", "filename": "world-systems/toi-2407.json", "bytes": 701, - "sha256": "b01c4f99c25e62ba7fbb3e0809e968b8d2164a710e05ec10064809bd022ed1d3" + "sha256": "1ff7329dc16176fee685f466ad7ded384eb7d5103d8aa8b6c5e2a065406bb202" }, { "location": "prepared", "filename": "world-systems/toi-2411.json", "bytes": 697, - "sha256": "0c7c9489bddb3a88a6d6b4cc781b1418b6e3c4c0b606181024cd552bd48a51d1" + "sha256": "59f085a5c9d7fa1f8a62569256727ed3dd6760dd92087f33f9567f96182b843a" }, { "location": "prepared", "filename": "world-systems/toi-244.json", "bytes": 688, - "sha256": "7b4bda3ceab88ff8e4a6a3574da1940441e657a274fcd05cecd63075d4956988" + "sha256": "9569d969795a2e6826e52ea6763eb5c95934796654c11571f4f3d0f27263bc7d" }, { "location": "prepared", "filename": "world-systems/toi-2443.json", "bytes": 692, - "sha256": "9a34756221ad41064f5680ae612f50242fcba45294b904a4201b2a03dd507629" + "sha256": "fbc9831dd8279a30990c8eacb4549d339335d1550dc706dc171291aeedad1307" }, { "location": "prepared", "filename": "world-systems/toi-2445.json", "bytes": 691, - "sha256": "edac730050f5b76ff00cfdbb7e1db256468dc5fdec081f790098dacf5e882ec7" + "sha256": "fc5fb971b7ba31470cc0899fddcb094df66a27beb8c8b84a133bd736c238e6ec" }, { "location": "prepared", "filename": "world-systems/toi-2447.json", "bytes": 702, - "sha256": "d64570efbb5288ea542d2ff6280428cb8773fab0d678ab2f4478136cdb921ed9" + "sha256": "5e2380e18d261ddb58d27a7c1c98947c923c6d0774a37003536613edfb8b7ed7" }, { "location": "prepared", "filename": "world-systems/toi-2453.json", "bytes": 695, - "sha256": "8906d4ebd6d457e29cecafd33121e17f4cdc08c110f31d92bf2ac579e52fef13" + "sha256": "ef16481785e6f73dbf3669a5822086c88e559cd0c3e14b26bd7f56c746cf25f3" }, { "location": "prepared", "filename": "world-systems/toi-2458.json", "bytes": 693, - "sha256": "a6f6db5670c9c40b1329a8de3377b20ccbd05c97f13b70de100f2b0da5791d6f" + "sha256": "e2ac696b4cdee0cef2204df579a95a984a6baf86081a82d0761c90add6e9718f" }, { "location": "prepared", "filename": "world-systems/toi-2459.json", "bytes": 696, - "sha256": "1bdd08caaa558f0170080f2b6a8db086e3fba395dc8a156ca20b8dfa37c04f34" + "sha256": "34f53a7a97dfc7b1c927f509f7e3ac694edc7d021541922f427f4d152dc32710" }, { "location": "prepared", "filename": "world-systems/toi-251.json", "bytes": 695, - "sha256": "fe23ae9a38be1321d0434c31f04dbe69886d61047408462dd78608ae9f1168ff" + "sha256": "b373d3d0905769977c248649adde5e1854e4348c03b0ab9d8bfed734b005cbc1" }, { "location": "prepared", "filename": "world-systems/toi-2518.json", "bytes": 703, - "sha256": "48146d60aab5b13815625eb8623b87dfa1a7f6bd4125213594fbb70832d58617" + "sha256": "aea2eb9959523e3c5a38ab665fb4b8cefeeb694d8438980968e4b76538a0d940" }, { "location": "prepared", "filename": "world-systems/toi-2537.json", "bytes": 698, - "sha256": "d25fea21a66737edecc8ea6b320def82c513e7e6aaa5c4eaec3f782afba30779" + "sha256": "f64bdc65cf489932107149d0ed18829374cf32cb01f00ef7d7a3523c596ad295" }, { "location": "prepared", "filename": "world-systems/toi-257.json", "bytes": 696, - "sha256": "fa0f093d5efa4cc749790da59906843202d89dfaac828493bee9035ceef4406d" + "sha256": "602f94fae81239f6b30ee3f3e51c8ac1022b19837a98393923251da66635e75e" }, { "location": "prepared", "filename": "world-systems/toi-260.json", "bytes": 688, - "sha256": "3d46650b677a5dbe0ef73fd83ec0022a7a7377990ef14a7073b8bd5fd851f0bb" + "sha256": "d78db0fc9be879d8371dd5964f401a9743d47d141c99cd53394ca6b11fd120e8" }, { "location": "prepared", "filename": "world-systems/toi-261.json", "bytes": 693, - "sha256": "0f3b4ce4238f969a2b52fc04f94bc8959ac7582da68255eb5a1796dde0865738" + "sha256": "8c667b94a1fcf9eeea262c0ba47219e447e165eb60cdd41d6c69ac8ce9413c98" }, { "location": "prepared", "filename": "world-systems/toi-262.json", "bytes": 690, - "sha256": "c895d5c530599b8aae534748c96aebacefb29653ac330ca44dadc9fe65beb7f0" + "sha256": "535fbf6dd1985e1ed65e49f0d4b03c5e5d41f42a31243ae6a9e16f6e4401cdef" }, { "location": "prepared", "filename": "world-systems/toi-269.json", "bytes": 691, - "sha256": "9371d68881c483d385eaf71033d041d4c58169bf130a35a1f5d5f5e3dd54267e" + "sha256": "211ee005f0b9b3c250a73d40e61f7e56045b98d4d154ff31262a041bdc636db5" }, { "location": "prepared", "filename": "world-systems/toi-270.json", "bytes": 1311, - "sha256": "ca3ad5d988fa8c137d4b23d01ecd6543014ad1de876bdc52b8e5d70d66291a8f" + "sha256": "4d5ce6d29f751949496558f8869da44e103696686c90fa13f15b774202007c86" }, { "location": "prepared", "filename": "world-systems/toi-277.json", "bytes": 691, - "sha256": "335f55e34730f9b05de730565bb55388a975ecfd5050bad52f3b6cca837b7962" + "sha256": "6f0bcc5859031e853de9d334406ef3e143a8bb99c0dcf550cea76bff78464b0b" }, { "location": "prepared", "filename": "world-systems/toi-283.json", "bytes": 694, - "sha256": "3eae5576f5c1adf820b5dc1318c577869826498243f51a05809fef5987c1b9ab" + "sha256": "e6cbbc7bce2df42dade51fd8ae98a1a6123d8eb5f62ccf483fc36033832804ef" }, { "location": "prepared", "filename": "world-systems/toi-286.json", "bytes": 692, - "sha256": "8c89194f1b3cc84e81975c07594cbdf25bed72e97b34a374639fb0b02d2478f8" + "sha256": "1c1ce9722e4325f6130a91affefd468d2b6cc39d541031c6f7fa69c547cf3e6f" }, { "location": "prepared", "filename": "world-systems/toi-2969.json", "bytes": 703, - "sha256": "e2d16e46ca293ff068d689cdda573caf499fbe469f32e5836d6d99cc1e6b444e" + "sha256": "35a3acdb8c7d6dc85b6152f91ae43df4130ddec0f9230b7084e000f90e38205d" }, { "location": "prepared", "filename": "world-systems/toi-2989.json", "bytes": 701, - "sha256": "832afea4945474e613b6d9f8f8e4cb359a03f132b11ae7420520bf33fa7ebac6" + "sha256": "ffa820507bc85a33af3d9e52cd31c1b9730ed29f835dcae6657db65ba6e99d78" }, { "location": "prepared", "filename": "world-systems/toi-3082.json", "bytes": 703, - "sha256": "ea5b96d189f08d87038c3976a3bdef45f21a5d706cbd13a0b2b41683dd3d0e2d" + "sha256": "165335d77373c63364907c9cf9b1758367ade6f28bea04727d41458f0c2b56db" }, { "location": "prepared", "filename": "world-systems/toi-3235.json", "bytes": 703, - "sha256": "0769aa24b424a43722aef70236d2747bf0c32263a7d5664d7cdd3a3fbbd16ade" + "sha256": "1d9023b2d9c24eab1b3c6c84aa68acb07ae76936a0eae8cbe01179713bb8a354" }, { "location": "prepared", "filename": "world-systems/toi-3486.json", "bytes": 703, - "sha256": "4d61a38c1d6f2663b829b1119633c0fb3247ca099b5c301c1c9b610250f194d3" + "sha256": "ee60ba8f281b6d0d2073c54bb539f767654073ca2f84b9029c00080996642cdf" }, { "location": "prepared", "filename": "world-systems/toi-3493.json", "bytes": 706, - "sha256": "388b3caf6d59beb84495d5565860089ff3b5cb88f9b98b212b079565ade7963c" + "sha256": "a3e1f3e0b1e0051f9851224cd8ce21d9838e96315f75c36b7cb3d6c694acae93" }, { "location": "prepared", "filename": "world-systems/toi-3568.json", "bytes": 701, - "sha256": "b13a2d5d77611a9d0645833554161e0b8f6d97e6e6633dbd6927145f6e0d9946" + "sha256": "70d6adc3dad1b55e075c51eb0db45899047d7f899d66c0c723ae6ee6b0f00b1d" }, { "location": "prepared", "filename": "world-systems/toi-3629.json", "bytes": 697, - "sha256": "9a0a33a89844e76d8588360f9d323f2ef8be0022f283642f99a3c5730d6a06a9" + "sha256": "7ee3c92238e48ac57e1924a071a445a67645c0b02789305cdef6f3d082fbc6b3" }, { "location": "prepared", "filename": "world-systems/toi-3693.json", "bytes": 698, - "sha256": "bd94fd5748df3e5002f7d33e62150d225a2fad7f3e5485c0389bbb1d021b444b" + "sha256": "48d91eada74313f95da334333fdcff8914dc03f97448b0faef0c4aa47a0c11b1" }, { "location": "prepared", "filename": "world-systems/toi-3714.json", "bytes": 697, - "sha256": "4a131698e0fd2756dfa66eae5c2f37e38be42529e9d587827a935085cf1efe96" + "sha256": "fb05fb07fc16cf215feb41b1108b3bc0d050c9444f01ad8f22ab6504db4ea9ec" }, { "location": "prepared", "filename": "world-systems/toi-3757.json", "bytes": 697, - "sha256": "6bfdaf2d3c8b77599e405807e549efe71bfdd734699625e882edb9ff1d801b1f" + "sha256": "dbddbc38ebe0862fa2f0a7be05aef7a2cad6e4c4710420b5636ea078f0d2fadb" }, { "location": "prepared", "filename": "world-systems/toi-3785.json", "bytes": 697, - "sha256": "2937c73304074863cce6a1882ac84bbe5098fc4e02c4fd7b0b315585d2d4091d" + "sha256": "1f423cf7b28a3b0257b4fabe4b8316ab5d257dae3d9516c12dd1f14899762f0f" }, { "location": "prepared", "filename": "world-systems/toi-3884.json", "bytes": 697, - "sha256": "217e4eaa354130c2b631981e893d6a918e0aa882e47d0ed49035ea3918fc1b19" + "sha256": "61af8e407f063560e9e7f4ff073569a3ab061132161a46e0c0744695f2859d28" }, { "location": "prepared", "filename": "world-systems/toi-3896.json", "bytes": 701, - "sha256": "96079c729e4fa5e21b38f461745524474845c739e2a13fcd7a71e826f8f15183" + "sha256": "e8107db41fb72ea1bc7297c8c40408594760f401ceeeb4928cf02069dabbb51e" }, { "location": "prepared", "filename": "world-systems/toi-3984-a.json", "bytes": 715, - "sha256": "77b38d497ab38156b9fb3db0fb4c2235da6ce61b6ac639ca910e91d1f839f900" + "sha256": "ed068e8abc3f32b86d90d4de05cd4a4d949535817c0b4c2b211451e6f8882bd6" }, { "location": "prepared", "filename": "world-systems/toi-4010.json", "bytes": 1332, - "sha256": "e9a31030781c07083dd39fb601b9a805718788d138ff41e6e64973231edcb692" + "sha256": "842a35c6ec978d189a355b4399b96703cd93a8c970c70867108e79627dff78cf" }, { "location": "prepared", "filename": "world-systems/toi-406.json", "bytes": 1000, - "sha256": "15690609652cbd4575c23ec15d97123000372de7e9a194770d58eed14ff91514" + "sha256": "ba29e6aee694fa434466759f8880f826abca38aa4efff308a853a27a8d6c4c1c" }, { "location": "prepared", "filename": "world-systems/toi-411.json", "bytes": 1325, - "sha256": "6e2d80b1de36754300b43e2b3d6181884a0dab7c300ccb0f74fb7a89597ecdef" + "sha256": "b83a5b505deb4c98d8546712c83c4915a8938587ae37300cadc743a19948c352" }, { "location": "prepared", "filename": "world-systems/toi-4184.json", "bytes": 697, - "sha256": "5ea4dbae9b1e245cf7bac47fc1e8efbb58c0fbc6171f5d6f77a6da2dd655939f" + "sha256": "50ac01dc9883378a185602993c76c6512e7c82f0d4d196c66de38a878db368c8" }, { "location": "prepared", "filename": "world-systems/toi-4201.json", "bytes": 699, - "sha256": "ff02e8c2e0d43ef203659be1f6caa62c92387fc5f768255036f44ca4d55b3172" + "sha256": "9a332ef2805ad2810cdf05db3621411f8dea38e52d96ff8862a4a6b8bf4de7d5" }, { "location": "prepared", "filename": "world-systems/toi-421.json", "bytes": 1005, - "sha256": "75fe81645fb1fa0c9ffa6a44abaa4e1e28b558d28ab0511de473cabe03634a37" + "sha256": "bd573ecfb8b7d03032792841d48de9fac0d441b76d15286a05f51b7a110c53d7" }, { "location": "prepared", "filename": "world-systems/toi-426.json", "bytes": 691, - "sha256": "c3e7def2bb0f0712b531b646d49eb7609d285e543fd74c30c73bd837cdee60fa" + "sha256": "b02423670448e64ab3e3ac0f00583384c6f99b07b88e1942a1fd7a112e42d3da" }, { "location": "prepared", "filename": "world-systems/toi-4308.json", "bytes": 704, - "sha256": "11f59ddcaec12aa94d406617180f254f916dd58060fc5bc8d07e0aa7c16bd400" + "sha256": "24875213daae55137f2567f587c62958844fc88f013dfea9fec95663943223f2" }, { "location": "prepared", "filename": "world-systems/toi-431.json", "bytes": 1000, - "sha256": "f0db977dc54824b0add5f7d4bbcfaba35e9e960cc223496405211f9b80ab4124" + "sha256": "d88cb4d2cf0cd05a77e078acc009980298446df117a1e147739377cfe202c549" }, { "location": "prepared", "filename": "world-systems/toi-4311.json", "bytes": 702, - "sha256": "3ea8c14b2017f2a1cf617417704570af97f0e25744511bf9ee6ff2f438dc2e64" + "sha256": "b4f7b07163a041d49a5f74bee063d179992a421d75cb7d0eb6dd1ab6187a9335" }, { "location": "prepared", "filename": "world-systems/toi-4336-a.json", "bytes": 1040, - "sha256": "5251bf2d140fd99386c7e4213c03b32442d04ab59b1bf47d0be295ec80e42440" + "sha256": "8837b2bad5c9540b4453274d672a442ca87a9e6d1bbb7b958ccba0a159a5112e" }, { "location": "prepared", "filename": "world-systems/toi-4342.json", "bytes": 1019, - "sha256": "1cd840c0114de43ea22cf5b15205c28cfe7c88942b86a4e1d8f9bee69bdc0ee6" + "sha256": "d5453bb37a109944ce640f8d404519b6d415e24722413ea9fd0728c3c42c75d1" }, { "location": "prepared", "filename": "world-systems/toi-4363.json", "bytes": 699, - "sha256": "33d2206b80b473bd4a5832667b208d53f275f048bbfade251f499e7bc6037e9f" + "sha256": "e343bcde7e3814915adcb4fe78bbe5b3260a105796a7aa91d618fb1b31f16f87" }, { "location": "prepared", "filename": "world-systems/toi-4364.json", "bytes": 697, - "sha256": "bd9877386869be64f03fc237c51b95b6e5ba45555926406a90487ee28975797f" + "sha256": "b713a46bd73b91e6911055a6300078ec1b3f55aa966b349c0a1a882b135294c0" }, { "location": "prepared", "filename": "world-systems/toi-4405.json", "bytes": 701, - "sha256": "babb687f6000d4f017d4287b7086a6f1714d6d8fa8c626b1e76dfc12a1a70434" + "sha256": "5a0bb5c7e7396b9d5c08f23304d68d6514083c0ab95b6eb05a4636d76977d9f4" }, { "location": "prepared", "filename": "world-systems/toi-4409.json", "bytes": 700, - "sha256": "36b89f610124f3f73ee56a00e00efea5c692766605f1d91dc16d8fa03246fb75" + "sha256": "6b69a496979aa9e67d8df97b39d8cfa71470d8aaf2e07406731f7ac8066d7eb5" }, { "location": "prepared", "filename": "world-systems/toi-4427.json", "bytes": 701, - "sha256": "abf12050f5b49d7b85364894b9a96258810d83680a2e4bdad27729a05542bc63" + "sha256": "e1462bbc264bb50ce73736e0f62e31148b28b1474b33d131fb99e0aa770b1513" }, { "location": "prepared", "filename": "world-systems/toi-4438.json", "bytes": 691, - "sha256": "9db9960b0c479dd2f9408b5fa6d6e9b2a8c23897333f9a5c1cccd2448343f0be" + "sha256": "9bbcf6d6e1a59ddba4baba175f01c9b080343a1ea1a052be1d0b3d412a4bbea1" }, { "location": "prepared", "filename": "world-systems/toi-444.json", "bytes": 692, - "sha256": "a551e26ac12d1f5a78f99cdf6ba02ad822feaf774a2f4d852779d04aca6f9d52" + "sha256": "763e736647de8349339bfb5dc62cf3d23887cbc0ac9428a83ebee3bf483a7ae6" }, { "location": "prepared", "filename": "world-systems/toi-4463-a.json", "bytes": 711, - "sha256": "c530fe85f03778d602addb24fa4c049512bb1f47881a7c4f8e4badc368d2571a" + "sha256": "9e74c0de4f9b4b78437ada0f24b5993e7831c5afec8e9a0ec9c237ce3ad69544" }, { "location": "prepared", "filename": "world-systems/toi-4479.json", "bytes": 701, - "sha256": "032a54dc9cc2fd59de5c47ef873828130e82fe0d7d40e35df02b610f0e36133c" + "sha256": "4031a20234802762228ecaf23a79b9d3c8e83e9e4ed0481704fb6591c1766488" }, { "location": "prepared", "filename": "world-systems/toi-4492.json", "bytes": 701, - "sha256": "8508307dd5dd1b0ce7e90424df5d9f09c59708a4f6a5a059b251c0b5978b82c9" + "sha256": "5003d6d7f9da021dbfde1933f5532c02f0d0fe3e2cee1be12fdaab17f7fc2839" }, { "location": "prepared", "filename": "world-systems/toi-4495.json", "bytes": 699, - "sha256": "2a52db1dd4c6943b2b3c74bfde22557fa292e5208d9dee0f9ab77b6871782a30" + "sha256": "8af6d5033a1b87ff86a84bd204ad4eae18a73a132a1ae849705e15811e93dc22" }, { "location": "prepared", "filename": "world-systems/toi-4507.json", "bytes": 700, - "sha256": "3e80f19178b9d5df0656734d4d747ee2d4e14283fc48bb2bd69b437cdb6f7d0c" + "sha256": "5782d2761307941a85ae2e0fef5568dcbbcd9d30d617f09e97c93f581104b902" }, { "location": "prepared", "filename": "world-systems/toi-451.json", "bytes": 1331, - "sha256": "f2d7c77665440ae1040c35c77b5c7fb1114328c33e16a3bf265ec597a60d8ec7" + "sha256": "2706d5685a43daabdd2539f14e0ccf714d72d5220bc66ea5b3a2725f445894ed" }, { "location": "prepared", "filename": "world-systems/toi-4511.json", "bytes": 698, - "sha256": "d8e73cbc9defdc72b404e8b76c94597c689fe670162788503a35ecd8c7da0d9b" + "sha256": "107d4d01cc48f10525a623cdc0ab73ad17012a257747c325f1def0a05571b243" }, { "location": "prepared", "filename": "world-systems/toi-4513.json", "bytes": 696, - "sha256": "dddc2d9d061122e640c006617dd6ce5ac2df1ccd6f63e6048fc2b1df4d322cf9" + "sha256": "432fcdd2f4140611ccdd241cf4d83ef6342f1e0bbc1b5a3f9753eeefc5190388" }, { "location": "prepared", "filename": "world-systems/toi-4515.json", "bytes": 700, - "sha256": "3b5d9bbf0ce389df391a9a5507dae35723636915de4fbc955a88d6221080f239" + "sha256": "1e4651cb6ef176eb5f8bd1f52fe51e32287b87f1a9d20c550a4560bd50692b37" }, { "location": "prepared", "filename": "world-systems/toi-4527.json", "bytes": 690, - "sha256": "a4f83774a442a883576d4ee1cbbc245e74db80ca1653ba2163392f93766056df" + "sha256": "18f9a4cf9d9fa7fd3e3d4d69b0ec0c846fe6b891404ef5f93f84e935293d419c" }, { "location": "prepared", "filename": "world-systems/toi-4529.json", "bytes": 693, - "sha256": "b32fb81994e8dfc77bb8b93a36af6821485ae1d13791ad626a9916b7bd5b6149" + "sha256": "b11ccfe1e4d4e5e752f1e6fecbfa9479b16ab159ada99f4721c7eaf7555a94e0" }, { "location": "prepared", "filename": "world-systems/toi-4552.json", "bytes": 695, - "sha256": "15a4569823d62db519c4afaecde6db30aebb5d9a31cc9b5a7ff1e8c05187c4be" + "sha256": "edcff7ace5c0a3d635776ae4647854954143acabfaaeff79003023244a3b3f46" }, { "location": "prepared", "filename": "world-systems/toi-4559.json", "bytes": 698, - "sha256": "49c1fd29caf77f35ded6a0d2dc8216f7ff30cffa482a1ec642ab7423525892c7" + "sha256": "69033f94f42e6421be24063bd5a74369c039b3676f2892af7eb5ff9406775a62" }, { "location": "prepared", "filename": "world-systems/toi-4578.json", "bytes": 702, - "sha256": "d37324b00fa00ea4bf876bc4c717c8e39ca6be82822fe23af0d731d87d4e3f3c" + "sha256": "658e51c37e38380e3bfc16e8e3c2a7cd0a0ec19df033143102c66e37f5665ab3" }, { "location": "prepared", "filename": "world-systems/toi-4602.json", "bytes": 697, - "sha256": "9368a6762202deae0435ff48088ad661ba3444e17f821afb729fd0cddc8ec271" + "sha256": "f4cd40e38ef96f9f19de0109342bfec4151bf7d4dc1ec12d04efcdbbf8f5da52" }, { "location": "prepared", "filename": "world-systems/toi-4641.json", "bytes": 700, - "sha256": "952172f49e5dab25544d1e9aaf33af5c0efda746dc763fe522aae1b0bc2a21d0" + "sha256": "2ddd872e199846bc63106c7b35d059c9acdc39687a47efa8e2dceaf356cd0cd5" }, { "location": "prepared", "filename": "world-systems/toi-4662.json", "bytes": 701, - "sha256": "66f9f4cbe34af7e4bf07bb6ab3604da0eb079fa04256c23aacf0e053e8f0da67" + "sha256": "6fc65cf19b9ff4ac02eb311ab25e5ef613b4ebb2e4ef86a4b1a805629320033f" }, { "location": "prepared", "filename": "world-systems/toi-4666.json", "bytes": 701, - "sha256": "0c2255d287f26d56acdaaa2ff379cbca9201ebc4ebf5b8d93846d2600ac839fa" + "sha256": "789a5344dd72e31ce1eb74acd5bf6fe0ba59ee170aa7fcc60ab9e61173867d0d" }, { "location": "prepared", "filename": "world-systems/toi-470.json", "bytes": 696, - "sha256": "4f8ce9e73a806d85ebec478a065120cf335d445ff86b5773c2c91ebffb2f004c" + "sha256": "9fe248920f7a732fc11fefa84a2cfcc8b4639c7856d602be0f4c2f38955a64da" }, { "location": "prepared", "filename": "world-systems/toi-480.json", "bytes": 690, - "sha256": "d85409ab62ef2b14c4a7f2b2f918c04dadc2bcf62d7e4310e23a50f8e12260be" + "sha256": "aa0a717f6b48469510d2dd52c8d935af7e6a70118c60d3dc55764824fa091141" }, { "location": "prepared", "filename": "world-systems/toi-481.json", "bytes": 698, - "sha256": "76e10fdd52b89c828afa096db5b59f05c56ab81e408d51dbe350849ca6650354" + "sha256": "d59dada45e6e2ac6917b3445a036b9e58fcea9b186cb0cefcbb04269da1746dd" }, { "location": "prepared", "filename": "world-systems/toi-4860.json", "bytes": 701, - "sha256": "6ef2cebb15518cf175289e2125a618770e4e4a88bceff8ee175e5e7ab2558bd6" + "sha256": "94164c3979aec34483d318c4f921f5e39c6d5ab553fab856be66a88e1a2b4cfb" }, { "location": "prepared", "filename": "world-systems/toi-4898.json", "bytes": 703, - "sha256": "8ce8d9b1353e5796db0c7e06ef37363e31c8aae9cdc4e24d2ee7e82bdb90dd93" + "sha256": "e99cd19920e512de3e64ac2f2db1d7ddf6fdaee23aad39e04063a467e6415da0" }, { "location": "prepared", "filename": "world-systems/toi-500.json", "bytes": 692, - "sha256": "2622f4dafa187a2fc14bf68eb585605d2ed0a6f085e7b8b15000ffbb7f974499" + "sha256": "11f1c050be7c549bfd0806e229dd2f02f3f5a249d2261df634d46f0878d04291" }, { "location": "prepared", "filename": "world-systems/toi-5076.json", "bytes": 698, - "sha256": "47ebbe837fc3b6eeb569a918259d283737b6a577582efa638f4a69af18c7f073" + "sha256": "23710c03d18572ea342ffbe4c72f232e8f57cc7c3cfa273dfedad351ee4aba84" }, { "location": "prepared", "filename": "world-systems/toi-5082.json", "bytes": 697, - "sha256": "a49155831dccb14f7f40e980a7c134c27835df3a75be538c3a393b6ed1be0312" + "sha256": "e73fa4a897a2f774173a8071623e311d0976dde2e9ffe24f0977003a1db0b2c8" }, { "location": "prepared", "filename": "world-systems/toi-5108.json", "bytes": 698, - "sha256": "7f57d0e11213337be86798acb3d108c9eb201842ee1d511574d70256288f15c6" + "sha256": "b901b7de1e4ce5f9e284967d29ffb5d23b83d6f4840056415dae179c62106023" }, { "location": "prepared", "filename": "world-systems/toi-512.json", "bytes": 694, - "sha256": "f2fad9dd6164b51278de95e4f4fd5a407629b47c2bf3bf5bbd59f24c2891c562" + "sha256": "c9ba576c984cd476c01fa8746c66e89a17972100cdce0afbc68b1998598775e4" }, { "location": "prepared", "filename": "world-systems/toi-5126.json", "bytes": 1023, - "sha256": "55151cc0e437a9664062cf4b3795b216417b84881fa5d06948eab466e4e750c0" + "sha256": "8b76a16a2aa181576a93ca01665aa9ea87d11aec470771b2e60b0644afacf9df" }, { "location": "prepared", "filename": "world-systems/toi-5143.json", "bytes": 700, - "sha256": "11bad6885f32b1cfddd6d9dc45b92c60444cc6aeca6904e8c05cdcdaabb48f45" + "sha256": "9bb393e16d1028dbc4ba9615ebbb1e9819b20e9b52b60ff2eccadcb933a4d5ce" }, { "location": "prepared", "filename": "world-systems/toi-5159.json", "bytes": 696, - "sha256": "37dda4a8e8016621889c37e7e176622a12498c64dd790b070e79a89e5a4cb843" + "sha256": "cccf75bff311ae7b3fbbad4b86d466a76f03d233420b05ee3105ba8899ce626a" }, { "location": "prepared", "filename": "world-systems/toi-5160.json", "bytes": 701, - "sha256": "a819919c3af49f60a3e3f077688ffd1239331197c19ca06554e1e35bfac1aa4b" + "sha256": "8375f077adc5c9ecf5df898a8ad2a62b250affc84d0639cb25403f045dcdea8f" }, { "location": "prepared", "filename": "world-systems/toi-519.json", "bytes": 697, - "sha256": "0a81f4177b33efd7448efb2fe3a72b669e09676359bd24f5c7f0a1ffb01c487d" + "sha256": "b1ea7829ee5e0a0da2fc9cfee3708a57506289f424e3b851f3f7342501ee3c15" }, { "location": "prepared", "filename": "world-systems/toi-5205.json", "bytes": 701, - "sha256": "3a2312425eb65a5a2f394c5adb03ce1cca9c7f5ca612d85eecdee22e77e9a1e0" + "sha256": "27a94867379b8a594d8eb041b55249cc943a8d5b243b5ca6d694b32f400e8cda" }, { "location": "prepared", "filename": "world-systems/toi-521.json", "bytes": 693, - "sha256": "3208a8bb88b45e6fb4c020baea266bf8805578539a091699836db63d185593d3" + "sha256": "9bd65617d24906f2ceddf581af322f7ebe4134248fd3310314c81661138eaa5a" }, { "location": "prepared", "filename": "world-systems/toi-5293-a.json", "bytes": 711, - "sha256": "acfee06343fa9ca2914e719e60ca92cf585845d47c3294944edb3ec407d2dea9" + "sha256": "9ddec2ce2d29763c2ca3a070027e8059bdabbe32935de60adbb93f028068a7b0" }, { "location": "prepared", "filename": "world-systems/toi-530.json", "bytes": 695, - "sha256": "60d18b3f688baee7385818c36490350f161dfd1bf3b603c6ba0d5c269bf37c4f" + "sha256": "18a80a3f721fb724c05b31830a8b74f77bc75e9171ac149d82947e5f233a9799" }, { "location": "prepared", "filename": "world-systems/toi-5300.json", "bytes": 695, - "sha256": "e1b604f2f4ad93fedefc588615a1028b3d447e989f87319eaa3f87bd621d9c01" + "sha256": "32e7480aed3c8de50d561096fdd4ecd94bb693db667989909f23e1c8eb532bf8" }, { "location": "prepared", "filename": "world-systems/toi-5319.json", "bytes": 695, - "sha256": "7c711f923461e3f92ca6b14cc0e405030e5004316b83033fbc68df980c0a316b" + "sha256": "e0451db55664d5e9aa0e330a26228c7d3838c0c9a137152b43297b42ab005f7a" }, { "location": "prepared", "filename": "world-systems/toi-532.json", "bytes": 689, - "sha256": "44b75825e03216456c25a58b90950166e87e493f88f35083112bce1128a21b3e" + "sha256": "fbc285611141a04c3c2c972c254fd8d558bc131e7459abb8c234b6a487029279" }, { "location": "prepared", "filename": "world-systems/toi-5344.json", "bytes": 699, - "sha256": "62d9f7cf0b72bf7748b00984baff985195186f3ec90251f6f0b2a9b88867abc6" + "sha256": "27c3cc98ba76652c2af8b4c6e22e9f235522d4697f64fa43198ab5a5fa9a47d5" }, { "location": "prepared", "filename": "world-systems/toi-5349.json", "bytes": 699, - "sha256": "35719d0a37b029cc22ea97a1bbb023c18e2c41fa0b909249583a9ef78c7b0e75" + "sha256": "d31e2a2b61d39d8ba22b148249d890ebe41a7cf2c87b36c40c0b71f22b8ea4cc" }, { "location": "prepared", "filename": "world-systems/toi-5398.json", "bytes": 1032, - "sha256": "75944ec433a394fbf38aaf8628fb33f3d947d164c99ea08416a7905ba2a990db" + "sha256": "6018b44a600aa0cad40aa0871317c948becd8d8156e2029f75babdddb2cf469b" }, { "location": "prepared", "filename": "world-systems/toi-540.json", "bytes": 686, - "sha256": "f8b01b64940990b3ca6e4617952b7160cc4249089ea6d5d8501ec56f231962cd" + "sha256": "24337bcf30860471f72aef1b1ef244750628588dfa93964e64206fe2241e4ed3" }, { "location": "prepared", "filename": "world-systems/toi-544.json", "bytes": 687, - "sha256": "fd535d2c76daadad739a953a6c88eaf2ca4a468ec1ba6b0b04fd29e3c1bfd192" + "sha256": "38d3f42ab55fcbea3b6757e4c65dd32017b954c63fd6e5d0f4a136cb6ff82a1f" }, { "location": "prepared", "filename": "world-systems/toi-5486.json", "bytes": 699, - "sha256": "009008d3cebab28c17b915bc795a5b6dec0b3be6d78b3169b85db886e6902074" + "sha256": "dac003e03f7ba1a996823e08d98d41b9f8d1c0b7e9b4323452d51c7aa9b68c81" }, { "location": "prepared", "filename": "world-systems/toi-5489.json", "bytes": 1012, - "sha256": "dfd49edf3bd074321d5e32658506be08f16bd4c18c3de3ef8a74371bc8a7eb80" + "sha256": "065dc41691382c1f6aa1a8afd58d6edd6dff09383c8af46f24b1b4816b72d566" }, { "location": "prepared", "filename": "world-systems/toi-5515.json", "bytes": 702, - "sha256": "5ad37e926c45b8ac6e80decc6d80aa20e419f5333a1062b10cea816fed278994" + "sha256": "7ce9a4242c7435008d5cebcce5ef07067827cc17acb3b1853c1bfc15240a2b39" }, { "location": "prepared", "filename": "world-systems/toi-5532.json", "bytes": 697, - "sha256": "14c3b30e2b34995f26648c545257a4d3790d1e0fa88165425839077407d0d496" + "sha256": "82eb66ce798bbce5d25fe5994f5496719b92966f6090c44775b04cebd2a2517d" }, { "location": "prepared", "filename": "world-systems/toi-5573.json", "bytes": 702, - "sha256": "ababc016a899294155c6cfb48a22953f15e112b41e0bc11014545adb1ce986f0" + "sha256": "395fbf4326829d993568e9a628ab73856c8cb0b2b4a65edd620e387ca3fa5a0a" }, { "location": "prepared", "filename": "world-systems/toi-5595.json", "bytes": 702, - "sha256": "1ce31a13d7baee4535ba5be95efb97d5ab503bb0394104b3afe9abdd740e224a" + "sha256": "a5059751def9a9d6960daefab94c819720a5253aa3daff4ea1ead8e4111fe181" }, { "location": "prepared", "filename": "world-systems/toi-5605.json", "bytes": 699, - "sha256": "18e88066a1a8c73cb3a0bd0ac6900213c55278b6153733f814ded9308c517133" + "sha256": "ff63c3211636a7061cbb53e5a4d23154e92b1e4cc1af747af44aecf3b24412b3" }, { "location": "prepared", "filename": "world-systems/toi-561.json", "bytes": 1640, - "sha256": "112dc3a73d501b6a922dfee94bebb5fc0db93e460ed37a50a9dd722bce8babcb" + "sha256": "4cab3d15f54f5ee72b096489cd46ec3ac87ff0db31be86143aa174cc54ae51f2" }, { "location": "prepared", "filename": "world-systems/toi-5624.json", "bytes": 1659, - "sha256": "432e8e5da52f59cba834d8a575777a18a11f16e0a5303e064f6994f7b97a233d" + "sha256": "cdea0486aa563a89b310d7574e4eabdaa5cae0abdaa0ca371079a0b503d13a4a" }, { "location": "prepared", "filename": "world-systems/toi-5628-a.json", "bytes": 713, - "sha256": "3642d05139359d721beb09ecd4cfced931410f9fe6bf6b75e53f54461cfcf89e" + "sha256": "62bd9c6ee6665f1c2fbc89174606d50dd4a5fe6f6663672f17a04cb158db8e3f" }, { "location": "prepared", "filename": "world-systems/toi-5630.json", "bytes": 703, - "sha256": "14704a70107decec3e4f300ae9c6cacc2d43b0bf506a7848ddefc0d611154714" + "sha256": "4ac45561ffc20a694a365a67da4f630b9038d9ea4efa9ba22e1857de59c7c0b6" }, { "location": "prepared", "filename": "world-systems/toi-564.json", "bytes": 697, - "sha256": "5bf3221a62f00a4d299333f79a0b0bed8027b56b10420a29019215316707a876" + "sha256": "4af2db7f6db6577bfd23c505d271d0d38e5b6516215ab7a84e52f3a4b27a562b" }, { "location": "prepared", "filename": "world-systems/toi-5678.json", "bytes": 702, - "sha256": "5cafe7e69e114a012dc1974fc58b56b293d90a04bf0fbf747a420b598b19466a" + "sha256": "c10c3208b002475f148534d8362488306f50cf5457e4b3753de58f82cfabb362" }, { "location": "prepared", "filename": "world-systems/toi-5704.json", "bytes": 699, - "sha256": "6435314bc6b0e68512289c2420fe949fd75c36772bb31649749dbaf47fe12263" + "sha256": "f14a8690ba6b2fe9621071d920c8812f20f24707d2257f4da9de82b42152b236" }, { "location": "prepared", "filename": "world-systems/toi-5713.json", "bytes": 699, - "sha256": "52962c774a634a438eee0bedd3e4f7e5b301ecb82913f8a4fc44bac8657435ee" + "sha256": "cd19f12b74360ead31a8ea6cf602606ceb137a91eba9638e769694873c8b85fa" }, { "location": "prepared", "filename": "world-systems/toi-5720.json", "bytes": 694, - "sha256": "80f4fdb2cc3f2920fad7caeb9c84302b459d7d4f4d5f832eb4d4e833b6873452" + "sha256": "9e6de61e4973a6567165d0030e5fbe0884db12c87ef68a7e32ea338118f2f5cc" }, { "location": "prepared", "filename": "world-systems/toi-5726.json", "bytes": 699, - "sha256": "914143e49aa709710cf58f9a1dab805dc4d270a62f9f7f71eb6c5de65dbe323a" + "sha256": "cfe87dccf0ef888a538816256d5c52240e93588ac14e3e7a67025d352991d6a0" }, { "location": "prepared", "filename": "world-systems/toi-5734.json", "bytes": 695, - "sha256": "6b3747d003495c6637340ad0b187e4e35aab9966bd956b15ad1e1ba45dbf2c27" + "sha256": "1d2995ca4774c696285e82d69a3837199490c0d0306d3ceeae7d738b5f8fb5ed" }, { "location": "prepared", "filename": "world-systems/toi-5736.json", "bytes": 702, - "sha256": "89b01b3162a825fc376b5cd389ded84488a262b52fda87fb70f1be8c8a9a72ed" + "sha256": "a76c91ca97b9690502ccc4faa04baadf724f2621bd91519989bd410448358787" }, { "location": "prepared", "filename": "world-systems/toi-5777.json", "bytes": 697, - "sha256": "177005a32bd1a3a270045ecde282002157287970dd240d6c9c89ba9dab11960a" + "sha256": "61b5da9fe3fe61d823101583f33d3a9756afc7c03e1aaf04f04078d325668416" }, { "location": "prepared", "filename": "world-systems/toi-5786.json", "bytes": 702, - "sha256": "d6ea35f6179c712d8ea88f32bcbd874be517850ca95f778650d9cf0c478f763e" + "sha256": "7fff644c6f06621ccdec055954c242d068f53d4387d397bd6c56e682dc338c05" }, { "location": "prepared", "filename": "world-systems/toi-5789.json", "bytes": 696, - "sha256": "1ff98e452cabe09aee47ed28ccc9d2954e599fdc2dfa923a0808d9390337cfb9" + "sha256": "a5ded71a8de57c9d62d70e0a6e669c8fa8f075b5f259de18592a44395a6aec44" }, { "location": "prepared", "filename": "world-systems/toi-5795.json", "bytes": 701, - "sha256": "0b329ee921cb55d796b6f90de7d74f087da854814f6f3f30aeef8bae82091bd0" + "sha256": "01bc481e1de8e3121fb87641d0ae6593f629e0d603acbcb315d366b570112118" }, { "location": "prepared", "filename": "world-systems/toi-5799.json", "bytes": 1011, - "sha256": "c92a89582d2a748a99d1e3f03e86648ac316b3af3ebf3cc2a50c721365900276" + "sha256": "2d0d4e2253c1ff852619ae2603d71a1b490315da1892e53f6d2964763a8bc370" }, { "location": "prepared", "filename": "world-systems/toi-5800.json", "bytes": 699, - "sha256": "afb18ea4bb21722c76cdd0f521f7f1833bf11270fd91dabcebf811a482c1ddb4" + "sha256": "fc835d6ac283364881aa3285126596ee39b1d9529f6f562b4636421257143aa9" }, { "location": "prepared", "filename": "world-systems/toi-5803.json", "bytes": 700, - "sha256": "388ef080de2844e12f81ddfc73df33c108b3614039bdb6917d6eb50010136431" + "sha256": "21bd2502f0553308cefab256252dacd0be43bce6cf01f755f2b8d4293c23ddde" }, { "location": "prepared", "filename": "world-systems/toi-5806.json", "bytes": 701, - "sha256": "299958d585d275c273e769185dc533631a5caceff2b6dac1deef245ddece3238" + "sha256": "4e1932a09aef90b2cf90ec23279d8128e8e2c1c1ba1cbc03ea1c900fae5864ee" }, { "location": "prepared", "filename": "world-systems/toi-5817.json", "bytes": 700, - "sha256": "7f78ea63496e0ca3b3d2419a4baf0b7ecd6644653f6219384f2892dd81173b10" + "sha256": "55405065ca1d136896cbee80634964ad712fbfb5fc3be1f7848be545ccb2a9f9" }, { "location": "prepared", "filename": "world-systems/toi-5916.json", "bytes": 701, - "sha256": "611aac129c33575ae24590c27aa4da00144801f89a87bd5dc04af40329d17c28" + "sha256": "aee15a8b27c74ec53561cbb8d0ffeae284a35a57ffbc2090828e9fbc2f9db9b3" }, { "location": "prepared", "filename": "world-systems/toi-5938.json", "bytes": 701, - "sha256": "403b7c5f7a7ab0b0f4a1b2f6692a71079566bbfb52f61d793a439c4eec942cf9" + "sha256": "90c74ba4e911695971d3b747a34f5cfd97aaad689c6646a49eb80a5151770a28" }, { "location": "prepared", "filename": "world-systems/toi-6002.json", "bytes": 695, - "sha256": "9530d53e56443c6c55fdc34c20e0b810caa4aa49f6c7a90aaaadda66064f54b6" + "sha256": "7bcf518eecb9c0b920f4313d4b6f9c3d3ed611db9a63a97ec9172da78151f975" }, { "location": "prepared", "filename": "world-systems/toi-6008.json", "bytes": 694, - "sha256": "c8a55464ce707605d321cfd4cc6d1c453570f2d8f730463f3928d7279179a337" + "sha256": "220f87f367c0a5e8427343357a86a5044b1dd9be40d5611e75bf92362f40dd05" }, { "location": "prepared", "filename": "world-systems/toi-6034.json", "bytes": 697, - "sha256": "00cd44d62d50042efc52284190fc4b719c6c5bffb8d1aec525a4aca1721ffd30" + "sha256": "c3129dcd2479964c67dc3eaf4b52575d88adc2d93e4154c1d5710ff520d10380" }, { "location": "prepared", "filename": "world-systems/toi-6038-a.json", "bytes": 712, - "sha256": "5e758be2c825835f7385a9cf0e72b334dd3db71af7cb9c6c9d22e358a9565561" + "sha256": "d2938d4d39903ac069cf1b096356f61863a7f51389ae7a312a716e991d9d7dd1" }, { "location": "prepared", "filename": "world-systems/toi-6054.json", "bytes": 1016, - "sha256": "a6fbe4aa27e331a8fa5cac84cf5f88b001fe8082ec1ea4caeb008790283677dc" + "sha256": "86a418cad6754c74822b9579b1fcc0980c875653ae760728eab3ec736de7aee5" }, { "location": "prepared", "filename": "world-systems/toi-6080.json", "bytes": 702, - "sha256": "d53fd1c8525d649ac9caee416951ba4d85d3ae42430e8d39a44536bae29dcb55" + "sha256": "0746ecf8dbda526ba1d71a0a9c5d6eef4d97f93c95a6cd59056254e45d20df2e" }, { "location": "prepared", "filename": "world-systems/toi-6086.json", "bytes": 694, - "sha256": "ff0cb0ed5d664de8a370bb89300203f14a588b721c1e8c620d1873d89bedcf8b" + "sha256": "d2e3d13220ce7b50f80d7f36c63f5e77af0e24ef1d38fe127cd6ebbed2b11a2c" }, { "location": "prepared", "filename": "world-systems/toi-6109.json", "bytes": 1019, - "sha256": "3867e0a9f2d28a172873db92842425d845c5ddaf83cb49776dd2b1eaae09c42c" + "sha256": "b678d35e715f7a38ca264676e32f67aec9189ce92a438d88f90ed3f45892a46f" }, { "location": "prepared", "filename": "world-systems/toi-6158.json", "bytes": 710, - "sha256": "a025c0e507cbbd33d6dabbbb49287777fc280856d0230d0ca34e3387214d8b98" + "sha256": "606f6d23b6e19f0e496359d3de3fec8c80e9145f1243ef3f213d85bd23bbec66" }, { "location": "prepared", "filename": "world-systems/toi-620.json", "bytes": 691, - "sha256": "daa165b70a2eb07c679a063d931b80482fb6f454468ae56a602871dd8fc42d50" + "sha256": "d3f18b79c6c5a53a3ede3d3a526eeb245b9b7742e71893af43eb206ef1762002" }, { "location": "prepared", "filename": "world-systems/toi-622.json", "bytes": 698, - "sha256": "f15c916a1b7c8ba6e77982f724f933c0b0aacc72a3bf92ac6fec68af1fc4f1f2" + "sha256": "887fb9334185d8bfe47dec8d7bc9dd1609d56d2b9cf1f6cccbe7a9b0db10f30b" }, { "location": "prepared", "filename": "world-systems/toi-6223.json", "bytes": 699, - "sha256": "8d50d8a2526b76038056c4a0075959d295fbcbb39c011ee37148bc137159a575" + "sha256": "517d44b33031448f38633ed9c90b8a89027186bb56423178a41cc952f5bf13c1" }, { "location": "prepared", "filename": "world-systems/toi-6255.json", "bytes": 693, - "sha256": "af94875dd0c35ffeafbf37f1ff8fb3eaa2598e394de2df2562d71f3362e1a04d" + "sha256": "92007dfd47a60594c274dd5ee980274cabc871447df56f638a98cf7b4d1205d3" }, { "location": "prepared", "filename": "world-systems/toi-628.json", "bytes": 691, - "sha256": "c054bfd7411cebd60c4ef81f3b887cfcf0b4b70e4cc2c343cabe5d068ad93bb0" + "sha256": "e59d33a6d793ea4e1622b2f9d575ca095de4ef4d73744c59112be1bedb04cd5b" }, { "location": "prepared", "filename": "world-systems/toi-6281.json", "bytes": 701, - "sha256": "85c84080ce3f4d6643d236c1d1ae96fa77568d9696f6c1edf6c2f02f38e61f15" + "sha256": "6981cbe99d17163c2d6c917d5b3f02368fbca998840da342ff642436f0feff8f" }, { "location": "prepared", "filename": "world-systems/toi-6303.json", "bytes": 700, - "sha256": "01b0a59fdc78f76c7cad795948829a376f11eba7fa63029d4490c85ad383e8ec" + "sha256": "d83cd0187e4bedae88a3478a0a0bd89907edbf06949ab0657e3d8de3d2c549ae" }, { "location": "prepared", "filename": "world-systems/toi-6324.json", "bytes": 693, - "sha256": "ff985b087f255ffa5f0bd73d4140646246e75dc22d64d4f9ad882f2460bdfdd4" + "sha256": "0752e65e319bf10a99ed81b4c55c64f5a7bd9617cb2979fa40c06dd602680dac" }, { "location": "prepared", "filename": "world-systems/toi-6330.json", "bytes": 697, - "sha256": "bd2bda9109cf3a96898375e2d1aae5f81ecbc71c20ec28a0561022201788a9ed" + "sha256": "d746f51ab93cd536f8df73180bf33f423659ab704f6359498ef827f40817f995" }, { "location": "prepared", "filename": "world-systems/toi-6383-a.json", "bytes": 711, - "sha256": "3a9d578162e4f9cdc8b5a31ff47d5258c42d1105e0baa56b041c63aeb09e61bc" + "sha256": "f2bed597f9623f44d48eddbadeff5e931664ced22b1281e3720e33d0f260f3bc" }, { "location": "prepared", "filename": "world-systems/toi-654.json", "bytes": 693, - "sha256": "4b5c8aaa9a5ffa7c486e61a347dd0d1e0a45f90dbc64d827202af8ece2e587eb" + "sha256": "cb4cfcaf4f87c4546a82ae85ac2a24ea3d6af6e3dd4020a6429d37890eec60ad" }, { "location": "prepared", "filename": "world-systems/toi-6564.json", "bytes": 706, - "sha256": "5c8e138e7d6a114a19aeaed4a70a7ac4d76bf4f1b0187fe75cdc7a83d2496813" + "sha256": "8853f3efa9a07553c2fa246f60f4f530b4a3b551f9fe3f01598c5aaf58391b0c" }, { "location": "prepared", "filename": "world-systems/toi-663.json", "bytes": 1323, - "sha256": "d85cbbe430d71f1f3058861caf5a6f3ff79704ff5bcce3296aa6d2ed67499a6b" + "sha256": "79a2fce2e0a795c2900821fcc69a3e42ea56a2bc6be3051e51bb7087bde0519a" }, { "location": "prepared", "filename": "world-systems/toi-669.json", "bytes": 695, - "sha256": "82edd296fa6a3f280f8c3c17dec7853c58439d8b8cc10f8e80b0c9ee8061a7ca" + "sha256": "c9bf3bc8c125d7f391f8d65321e1e37f561658ab80fca26679e8eb267cb3e211" }, { "location": "prepared", "filename": "world-systems/toi-6699.json", "bytes": 699, - "sha256": "ddf2cd6cb162adf29d80d0aecbeac4d025539aa5e36c2ead4a8f2a2120dbf536" + "sha256": "b5dfc35b88d93bb3419aa4e6eb56c4ff7c018b136475092bffd5049abd40db59" }, { "location": "prepared", "filename": "world-systems/toi-6716.json", "bytes": 696, - "sha256": "e2366901c744ede387dc95419f2df7e3ac7a306b3fd04cd965d8fe54f611f6ef" + "sha256": "5c0283a5e6e5dd66f09d57998d9720ede3a00049a3f8653b63cd0f8c64a6934d" }, { "location": "prepared", "filename": "world-systems/toi-672.json", "bytes": 695, - "sha256": "38d21cb4c87c38c5441a05757950f7a3d37bdb640ac8945c6d5e2bde382a18a6" + "sha256": "d21b88f3e17e17398d39aeedd94fd2d3780c63d98c1c498f27ad8df5b8f147ae" }, { "location": "prepared", "filename": "world-systems/toi-674.json", "bytes": 693, - "sha256": "203e3873bd4aedf54e0727163725fdaf748db32f0eb157072ec480aff9d6e4c6" + "sha256": "3d8c6db24f5a6c40e8dd3ff227c4ff1a469a1c787c71301619461f539c7b3cb0" }, { "location": "prepared", "filename": "world-systems/toi-677.json", "bytes": 698, - "sha256": "082ad62ccac545518b777faeafff3a2b8147563bd5905663ff91aeb40609d222" + "sha256": "053e541f7d40a96565d2af19f7b131aad2fb7c4904ac5a0e91c536afab6028d5" }, { "location": "prepared", "filename": "world-systems/toi-6894.json", "bytes": 697, - "sha256": "c5695561e7a866bb9fc56cda9aeac1abb8a4fa7e50687f4c6d3cf45d5dc0439e" + "sha256": "fedd2d6e8a6bfd5c30591e5a656a8572190d4cfb21e4e6a2887b2eda9e016afc" }, { "location": "prepared", "filename": "world-systems/toi-697.json", "bytes": 694, - "sha256": "344098f986f254ccbc4beeeb82392f02530694ee264be8b57abec139cc9d4d9f" + "sha256": "20caa54884fd297f311289a5a6fce9c8a0fe0ed25ba0a055e9201da59db26ba0" }, { "location": "prepared", "filename": "world-systems/toi-700.json", "bytes": 1623, - "sha256": "1e563109542de3d4cc8c0536ea828a99bd65000e9d160329bad6194f44a71fce" + "sha256": "42876a3035ebe5fe04b745a143e4c52a53f3fc31b6d16cf3583bd3e22a64a238" }, { "location": "prepared", "filename": "world-systems/toi-7009.json", "bytes": 701, - "sha256": "0ee883d71378c619cb33fa26b8de26b78ccfc9b1433a29c5315b90492f373618" + "sha256": "cadfd06ceae9c379df662100af2f6d05ad8adc1b504bf452f0ebfeaf06566310" }, { "location": "prepared", "filename": "world-systems/toi-707.json", "bytes": 696, - "sha256": "218e1c1dd8acdc9577c45f9ab6382fecded2702655883a557631116c6e7f19cd" + "sha256": "266d22e0e96272ad2bb05b561e1cc11f5885dfd5c96bdaee6d04dd03a41bb68b" }, { "location": "prepared", "filename": "world-systems/toi-712.json", "bytes": 1320, - "sha256": "a2e959e2e6e1d339309fee51e064cddb57732ea17d914db12751f157db00793e" + "sha256": "ceb50e148bf83848d6c95b2b37f3316f1118cf71c55b3272e6b569d3a44e8669" }, { "location": "prepared", "filename": "world-systems/toi-715.json", "bytes": 693, - "sha256": "dd7e69430f10a2ebb3bf449022606e3f2beec37634edaa93d4f77f332e230dc3" + "sha256": "7530add6dd061bf5412e3e511c727a50bf55ecb7926bbe7b9eebd7bd76cf9260" }, { "location": "prepared", "filename": "world-systems/toi-7166.json", "bytes": 695, - "sha256": "622e642ea78d2df311c4071f5b69daccf45a39377a6319064ee21485aafdc36b" + "sha256": "e65a82ddea9fdb04e32e0cbb3ce387dd72c7ec9c908c9115ff5c9ea082204c9d" }, { "location": "prepared", "filename": "world-systems/toi-733.json", "bytes": 695, - "sha256": "37233fa875d78a2e9e9223276fc8d5f21ad4531d6d054ce8c271ed6936225833" + "sha256": "197fdc172f15db809a5add35bec17574612add1c632c81e59cc42fad89e49e0a" }, { "location": "prepared", "filename": "world-systems/toi-7333.json", "bytes": 701, - "sha256": "c90f1ff485dc1bf439f02851b518ead0ae30fae302a25383ae0af149d3a51965" + "sha256": "b2401394075092019d81251fa09a9f37e69be71990a02771b8e61b588bd2d3a6" }, { "location": "prepared", "filename": "world-systems/toi-7384.json", "bytes": 699, - "sha256": "ee3faadd87fac9cc518b7f3f09b006b1726993a1830240a272b6502b70f1ab04" + "sha256": "f675de5aa395de78761dfc43334bd57c23ef55986b779f0e625f8862eb721187" }, { "location": "prepared", "filename": "world-systems/toi-7393.json", "bytes": 703, - "sha256": "31129e6155ecca7b6cab5c9a22faec1e423f33f982b09a6bc10229d8672fc931" + "sha256": "a01419217215c33117881cd346668254d5c9ae14d98c59ba0768fbab60512fd8" }, { "location": "prepared", "filename": "world-systems/toi-7394-b.json", "bytes": 717, - "sha256": "c0c6cee270088553fd9102d68dca18d091fadeae4fe271faa8d9bf3fcd53d20b" + "sha256": "d4e8dbb6b88543c503160c33430ede53b14182d41a00308d10a2ebf1aeefd1b8" }, { "location": "prepared", "filename": "world-systems/toi-756.json", "bytes": 697, - "sha256": "18feebb59f892c4289b83f25aad76f8d5ff6d8d69da5dae99efeb51033266745" + "sha256": "40d7f11c514902d1c27ace39225637ae64096841d6c3f6dfaa2829a28b6c61f8" }, { "location": "prepared", "filename": "world-systems/toi-757.json", "bytes": 698, - "sha256": "179701b368db5ed4673807915ebe8ade19ea13a430e5cba31adb3d3f0cd6766e" + "sha256": "de76845b9b70b37642a2fee82c8ea68ba8ce00c9f7937590cb4c6028e2256120" }, { "location": "prepared", "filename": "world-systems/toi-762-a.json", "bytes": 707, - "sha256": "d78164dee95519d53eb503ba62a7f40aacb7b8d5cd0eee82a876ebad015dc7e7" + "sha256": "9055c6c70fa84bf531715dffa1fc7678c4835b1132dfb1ac2a5c4cd699f29ae9" }, { "location": "prepared", "filename": "world-systems/toi-763.json", "bytes": 1027, - "sha256": "4d47ee01d968e26b72749d979fe1e74692ccc5be57722cd0da0ae4f640f62c0a" + "sha256": "b943e169d64a00651fe256fb6e0f7e73757a2f31b895c964d8bf58485d16e9ed" }, { "location": "prepared", "filename": "world-systems/toi-771.json", "bytes": 688, - "sha256": "e1974fd9d0cccaa65a77f8a445bdb8b0c122e57b5ddb695e747138891048d7ea" + "sha256": "224db3562019a13a5204a9aac450aa6fabdd7c6c2e7080e0be01bfa9c1964132" }, { "location": "prepared", "filename": "world-systems/toi-776.json", "bytes": 1001, - "sha256": "cc10c7cb6b63b114fec1ba1cb188010b5f54cacd21e7cef77c49133a51798df7" + "sha256": "5aef7442806d3614dc465780ce1b46ca174ad9bcff8996d9de0ec2dbbb3b753d" }, { "location": "prepared", "filename": "world-systems/toi-778.json", "bytes": 699, - "sha256": "70af15438e5ed920891c3d9ddd31b4f1ec7a92bcc0f49ca36ab488692090f6a0" + "sha256": "0df12d76f4b8ee725a87a2e1271c23598d6895850c71acd527e9dd23888e346a" }, { "location": "prepared", "filename": "world-systems/toi-782.json", "bytes": 695, - "sha256": "2ecc7515ab52eda289d7d65f2b86a6e928c717bb3ec355173f6a35517d5f0f85" + "sha256": "a04a241586b3de528b291997928c3307579a752774bd5540a699ad3ac7661613" }, { "location": "prepared", "filename": "world-systems/toi-784.json", "bytes": 693, - "sha256": "66cbd504628381dce2905ffd6b1488b71e01ce8da18c1b75d03518c02c59e850" + "sha256": "4cb4ad74aaf15e1a2e7da6942d49dc2e293661bb7716aead93a0829689bbf932" }, { "location": "prepared", "filename": "world-systems/toi-799.json", "bytes": 695, - "sha256": "2314ae1f67b0a8e431d5850db997bfd160aa276b71c76a2c65e072417102e753" + "sha256": "5397fab5e28e02d341ad7b55ad1a7047097f6167298aee72550471c33c245286" }, { "location": "prepared", "filename": "world-systems/toi-808.json", "bytes": 698, - "sha256": "d1674e04c71f1cd711db0ef1f07bdd2637e519926023c310577a7b088a4464a3" + "sha256": "22fe730e4f1c8a285fa6fcf440479d8849774342e3de15dd3f8a1a94389881d4" }, { "location": "prepared", "filename": "world-systems/toi-815.json", "bytes": 696, - "sha256": "7efb9656360bb7c2b2b945bc10ce45496ead7b1ef3433cd804899058288ba207" + "sha256": "d7b91b1fed5c05033dbac435c39e7e0a343c7f25663754da8f8b39cdecee7a6a" }, { "location": "prepared", "filename": "world-systems/toi-824.json", "bytes": 695, - "sha256": "100912fd853aaf14275b9eae68b17af1486c6c09ce5f111a972ad99353c1a5ad" + "sha256": "58d60baca5d27d6973cb124b2c9052b6dd01243cecaf0373e2a038b9d7481898" }, { "location": "prepared", "filename": "world-systems/toi-833.json", "bytes": 692, - "sha256": "048ed52cb7a909e329877d68bf41a857c21bd2bcd31e0ea7b72a8f6c92c8df2c" + "sha256": "0978a8e0865abd4f27558bcace6021cb8cbd92af9f28e00c29aa6d558f6e960e" }, { "location": "prepared", "filename": "world-systems/toi-836.json", "bytes": 693, - "sha256": "87860f9d862063bbb794158a8d3764f22e7ce17e433e54ef814d88257281e1a8" + "sha256": "4ed534e11c47532cad3500f4a065145f257b67235210b071969b232328dd4a50" }, { "location": "prepared", "filename": "world-systems/toi-837.json", "bytes": 696, - "sha256": "e20a16ef914a1edc7a201ba0df8928dcb72356581027b7979a183985e4a586fa" + "sha256": "34d10539260fd340f8d1ac3f49dd95d933c7caa9762c980b09d570441c54db7d" }, { "location": "prepared", "filename": "world-systems/toi-871.json", "bytes": 694, - "sha256": "bce4b249994789c15fb6ae8a332d4e53cd4d44ca28c077aead176e92d0c4b664" + "sha256": "bd2c34059d64a3faae22841164f05781a143b981d7f80f39bdb07c624f5f7b4c" }, { "location": "prepared", "filename": "world-systems/toi-880.json", "bytes": 1008, - "sha256": "cad90169b5a67130cc89ce8dc73a312ac4c8c3bd9954334ec89061e8736ad71b" + "sha256": "05cb3498cc0200b4521d438f94dbbfd2b1338615d6b885a65c7bbfd43ce563af" }, { "location": "prepared", "filename": "world-systems/toi-883.json", "bytes": 694, - "sha256": "b31e8e5305f49f352a1dbb1d83876d941fa13a0e554cb0d72b9827d34778e965" + "sha256": "5ad55fe6b698bb96d73618d18e61a65d11b1c346281c006e97840839baf690ef" }, { "location": "prepared", "filename": "world-systems/toi-904.json", "bytes": 689, - "sha256": "879af034cd898410d8426a30aefaa5bfb9ab113a1ccac09281624d385de2377f" + "sha256": "d7ef78286e1c0b6eea1e5a865ad3b24f8a5b67742a378e3242ea2602a33edb4d" }, { "location": "prepared", "filename": "world-systems/toi-905.json", "bytes": 699, - "sha256": "3766ba22093c05c1bae1010caf04ec69a40ac22c264b3a13c990f2bae12d2cfd" + "sha256": "1ddc3602490fdcd993b17ac8b1ddad26308908b5c1b1380a72c7f1540023ca74" }, { "location": "prepared", "filename": "world-systems/toi-908.json", "bytes": 691, - "sha256": "4ac9b3b9a9121d7ddcdd15e479163e2b8e8738ce41c1a7baf6b9916bb38e9072" + "sha256": "53d60d33e400c8511fafd83246315feb818c126ca812881e95f8eeff43e845d0" }, { "location": "prepared", "filename": "world-systems/toi-912.json", "bytes": 691, - "sha256": "7ba1a1e66cbc7447551f2a8297b001163cde80308376547b1b8bb81f5f33451c" + "sha256": "8f7f16b57c7cc021e877c5d9440d3ba0a32812657f6428f10d6234f616789137" }, { "location": "prepared", "filename": "world-systems/toi-913.json", "bytes": 696, - "sha256": "3cbba4279eb138494c5fd12469afa7034a88d458f75b0188214a0c055c137ffa" + "sha256": "cedbcc099e6a53f730fe13a393354c3105d6126a98cc14ab034c4becd646008f" }, { "location": "prepared", "filename": "world-systems/toi-929.json", "bytes": 695, - "sha256": "4c8a83c638e4f0da6209eadf8682025c66d72947c95ec777c9704ca4d52a60f1" + "sha256": "6653d85ffcab97dd800e2b9eb3e625c4d214f211e28e2f4ce1bd676c972d2724" }, { "location": "prepared", "filename": "world-systems/toi-942.json", "bytes": 1013, - "sha256": "0e55d251a8eb0c9e799fcf7fcc26dad7fb1e10a259c0a033732c877269c81032" + "sha256": "f28e8a6e707e96a810df863d1ac303b1135c2c73317b29c064cef72e0862f272" }, { "location": "prepared", "filename": "world-systems/toi-969.json", "bytes": 691, - "sha256": "f6b6dd2d2ecbaaac4a52aee3cdf0f65a06bb2904778a2d9d977b13ebc4fc3e97" + "sha256": "58fe46e9a3a9952748febc6613419c1569b67939ed264efd4c38917aafbba0e3" }, { "location": "prepared", "filename": "world-systems/trappist-1.json", "bytes": 2670, - "sha256": "decab20934c66e9febbe18af6ec97dbdf04cc24fb524e3779c3cd6c6c65612d9" + "sha256": "48740527fd936093e09f19aea8d951d4b5ab7a859fa9fd7f1e3c2cecc199c1e0" }, { "location": "prepared", "filename": "world-systems/tres-1.json", "bytes": 687, - "sha256": "3e98abcdd00074b2b23c0ab77f273d34e2da6bddbc1d536c69272b8134bb11bb" + "sha256": "10452e3ca1b43bfbe4c040ef02f722073b10b9d57d5d78d05db3b13aeac5b474" }, { "location": "prepared", "filename": "world-systems/v1298-tau.json", "bytes": 1034, - "sha256": "33dab796d49d76b75aa267d84371d3f5e1d8a81ba13c9fc9b4f61e4d0ab0e97b" + "sha256": "bfe96ad61424a718370a88f070710e741938466e5b24cd0453ffcf208f6893a6" }, { "location": "prepared", "filename": "world-systems/vhs-1256-1257.json", "bytes": 1378, - "sha256": "2f8349a3c7dbe3e4ccc62d83627e6a8065f9e4d4310cdc914080a5202ca8b7cb" + "sha256": "1575581b1da9776aeb59853115d41734087bb2479f682a25ebcdd72fa6486363" }, { "location": "prepared", "filename": "world-systems/wasp-10.json", "bytes": 691, - "sha256": "a040b6a4336782239159001e59efd9f74f3d2607c4648698c666b395a15528cb" + "sha256": "264a0cff1a3321652d09466de4bbe04eaaa26c5e8e53d9bce74b5d33fd83adb2" }, { "location": "prepared", "filename": "world-systems/wasp-103.json", "bytes": 750, - "sha256": "af547f456d623cdaf46cdf2a3ff455ddd7206ec45718372f9008851138986f49" + "sha256": "d98ac7d0394483dec4fc8d5dc708a9c572e3558ff96e109d1eb0d007abc328fb" }, { "location": "prepared", "filename": "world-systems/wasp-104.json", "bytes": 699, - "sha256": "08cfb4a771f44bcaf5c5842262123dd6b0ae2cd855f12e5ef2a10fe538ab7b62" + "sha256": "01ce8a071e366a49e79b030026ee7f8cb5e754fc8414287212f00886ad490f7c" }, { "location": "prepared", "filename": "world-systems/wasp-105.json", "bytes": 702, - "sha256": "8d8e15e72a41e127f28d367d0aabce4e34715d685a98e4a6dbb7a6ac705bd9ea" + "sha256": "2c033950b7c12cacd529f385f0698e49b1b4c99df48ef525d377c5a87f7a88b3" }, { "location": "prepared", "filename": "world-systems/wasp-107.json", "bytes": 701, - "sha256": "6f3bf0330bb05b96d0e10ca3ce0536004cc776db82230e5295d6517c26533b9d" + "sha256": "7dd62c0d21c5c532cf97db0bdbbf01c9961021e9cfff92d984839956d6e2df8f" }, { "location": "prepared", "filename": "world-systems/wasp-11.json", "bytes": 693, - "sha256": "458a98458fe93ca9fb54fa893a0b51144a6b3c4cfc661c83c9904c5c31c57a63" + "sha256": "26e0763349e3721d982247238877953ff7c5cc211c606c8fcfc19e408d49c2f8" }, { "location": "prepared", "filename": "world-systems/wasp-117.json", "bytes": 702, - "sha256": "e1af312a2ea0bad7d54b38603706a65e04a97a2c5d5e1faf4128b5360cb590cf" + "sha256": "f450af5b44cecaaf9689a1c1411abc1b52d089844f2fa59c619794656442de96" }, { "location": "prepared", "filename": "world-systems/wasp-12.json", "bytes": 696, - "sha256": "cb7744eb37ac8b0d4ab371a6da22ed1a6ecbae97fd01f7a65a3593e16a958b5c" + "sha256": "b2b049edb96de24e2367541d224b50b09e2cf420abfa68dc86f68b2697a2ebae" }, { "location": "prepared", "filename": "world-systems/wasp-121.json", "bytes": 728, - "sha256": "31082ab0ea3431ff2243bfa9660f60e77cec48dff1cb3f20b1211ea32f9c8c6a" + "sha256": "028f153e0c63445f59833bc02722c3ba37a36c2144d4a49826345501a56594c3" }, { "location": "prepared", "filename": "world-systems/wasp-123.json", "bytes": 703, - "sha256": "b67a1473b03108240d2828c0d7014ae08310bde345c4095b4233fc0ae2e097fa" + "sha256": "394e4aab25e79654a4c6e7d98b76becb2d08760eb8843bf210b545fc661c9535" }, { "location": "prepared", "filename": "world-systems/wasp-130.json", "bytes": 706, - "sha256": "5d5c51c01dd917dee76718f40cf4ec52fd500ccabc66f58940cbc1255f3e3822" + "sha256": "072c75abdb3f0d115aa435deaa655fcf22cbe2cc5812f2c0b8e18aac786290fb" }, { "location": "prepared", "filename": "world-systems/wasp-132.json", "bytes": 1031, - "sha256": "2c89b8573b7013aac90696b0b2f734c4fe8bbd990425b2bba858b88fffa3d615" + "sha256": "afbd02e6e282de4809b0f18964028907b049836bb7e29f18d85388f85d513ed1" }, { "location": "prepared", "filename": "world-systems/wasp-14.json", "bytes": 697, - "sha256": "2bb5401ce4727f75b92292df3b74486ce8074dcfb1201910a580fd027cf7646f" + "sha256": "5f86c993804e0501f5f8fdc089279bc16648b9a176480a0df0aaf426ca1e2a2a" }, { "location": "prepared", "filename": "world-systems/wasp-140.json", "bytes": 701, - "sha256": "118704a057712fa9ef1d4690bf7a5969079173990d617a0854ba8859428d529a" + "sha256": "9936b1f19e8a86c6089bd1694ad9643ddce1997266162eab2e716726b5ea53c2" }, { "location": "prepared", "filename": "world-systems/wasp-145-a.json", "bytes": 713, - "sha256": "d03b6677c9531355c02dcf99fce858e9b231fecff3dd2290caec14628b750d93" + "sha256": "066a4a33fbda24ab4654432d54a7e36fa00a139fa9f7ab9af197ac3c4f9b726a" }, { "location": "prepared", "filename": "world-systems/wasp-156.json", "bytes": 697, - "sha256": "d445a09ff752a82d3dafc9d82fc83ab59e8cda823bae6be61519d81ac88ff66d" + "sha256": "e9e7328867c880f5604a974311a511fde86b3f3fc0007de8b2d0485a5b101630" }, { "location": "prepared", "filename": "world-systems/wasp-157.json", "bytes": 705, - "sha256": "3c211975dd89ce65944f02f1ffcfcd56699f26f5af3f6c413a9089ab7d10a1a4" + "sha256": "f382309b1153a8277236543d4a68d1ba457097b7f002b9e7b9f862769accf624" }, { "location": "prepared", "filename": "world-systems/wasp-16.json", "bytes": 699, - "sha256": "90007e3428efc729e55a91b02a315f0fbf383706071c4c8a24d019fe225f88d7" + "sha256": "e85ae9a4d8510a51b575bf5031dabf848505d03c5e9cc76ec666199f7cdf8276" }, { "location": "prepared", "filename": "world-systems/wasp-166.json", "bytes": 703, - "sha256": "7d2076242b761ba52046167bec6bb50965a254ee4dcac6255c6506c7d1e3d908" + "sha256": "3a2bf9da87fcb192c2b62520e4af8121b47d7c962accbe54971374ba06f6ab71" }, { "location": "prepared", "filename": "world-systems/wasp-177.json", "bytes": 702, - "sha256": "bf04aa506bc11dbed9f3fa29a5f3dc41f29d06156d9d14fdbe098d1d461d92c1" + "sha256": "8823358db0ad66b9bb49d312797969e8aba6cf859ceb03dfe760e645a9c68cd4" }, { "location": "prepared", "filename": "world-systems/wasp-18.json", "bytes": 719, - "sha256": "93ad9dd1a6d1ea3ff2e3de88cdea3245aa96cd62b4ba2b081ad4973bd13d4a43" + "sha256": "a93e6795223cd7163cb12d2d3a44c75b7f89470845bd06de81b5f6b855285872" }, { "location": "prepared", "filename": "world-systems/wasp-189.json", "bytes": 704, - "sha256": "d800c27fd85ac391216154edbc3d1fbd4d0379c2ca154d2dc36a4497245022d9" + "sha256": "f8ca3b14eda66b298ec6f069481e344b1e723f297b5e90fbed7acaa0c1824585" }, { "location": "prepared", "filename": "world-systems/wasp-2.json", "bytes": 687, - "sha256": "b4c93bf59b385e5945c1eada1c582efa5ecddecc235ba00e8d2f54045a3863d0" + "sha256": "a4c057b775a0ea7815b1f0c418b389b1e429c908ab3fe3d18b75cce56c906201" }, { "location": "prepared", "filename": "world-systems/wasp-29.json", "bytes": 693, - "sha256": "aded91f0fc3a97435bd5fe26988fedcbefe375620f256070e347b975c24cd8a5" + "sha256": "8f9e355f2deb262b6d2c245a29d3c20c055837c3187a9c045e326036ba7d348f" }, { "location": "prepared", "filename": "world-systems/wasp-33.json", "bytes": 692, - "sha256": "10b28433b1cea902a33eb305382b047a37a4be336d4dc3516e153dde94bd7dab" + "sha256": "87a00c459ad8c9181529ba8823e3f1096c0e053cdf0a27603049e3669161a1fe" }, { "location": "prepared", "filename": "world-systems/wasp-34.json", "bytes": 695, - "sha256": "fa4b415c368172f9da40af71d28d5839bded483f1e0cc8f2139fbb9adfd89bcc" + "sha256": "117de8fe5bb44c511a50451d2387a6b6afa4e8573fa3a3572f4aa8ed0c70856d" }, { "location": "prepared", "filename": "world-systems/wasp-39.json", "bytes": 695, - "sha256": "d218b46a6cd99e9db5b5e8f94ec2555d5ae228814e62d8d642b2d043bb7f7611" + "sha256": "0db2289b016c1f1bb934ee492cba14b7eb1b5475fd7bb81badf41dc7e5760598" }, { "location": "prepared", "filename": "world-systems/wasp-41.json", "bytes": 697, - "sha256": "08169e8eab34d1138197027ffd38f5ea7e35b84179eac4dc269ce41abccb88d6" + "sha256": "f1a60e2f93de4c3adf6eda89b548dd4f206045b8a64c7a37a98ba2946b76c0cd" }, { "location": "prepared", "filename": "world-systems/wasp-42.json", "bytes": 697, - "sha256": "6e57c75018a8facd17d486bd291e0b776bcf37163946c6b1876a3d969f48de14" + "sha256": "d7c95a662c0024a2bc65e5c25b3a0196ba126f91a71991f08ca86b457cdf4867" }, { "location": "prepared", "filename": "world-systems/wasp-43.json", "bytes": 719, - "sha256": "1229851f6dc9086e8c9d5d0e216373616b8b0ae2e84d0a9c2d76eba4d7edcd9a" + "sha256": "31169d080c6204e6799c1b8ec4c0d80e0bc79ab98a0c96d38b1dd71223ea40a9" }, { "location": "prepared", "filename": "world-systems/wasp-49.json", "bytes": 695, - "sha256": "3db27579ff0a3e8e1b72c29a5561bfab31ec695eb5d1541a438ca3640551fda6" + "sha256": "821abde8ef8a0fdaf4fa3bc96f3d635b61d83f5be6da3f8070e40fe6996b5e9a" }, { "location": "prepared", "filename": "world-systems/wasp-50.json", "bytes": 695, - "sha256": "3700b74c0c216256e4c98c92617bacd246827dc78cab4975d251910c1c5ece6f" + "sha256": "5cc2ced027a59a0a51c99bb9a8af1f09d46a84c18c7c6b748d58748864a6d963" }, { "location": "prepared", "filename": "world-systems/wasp-52.json", "bytes": 693, - "sha256": "70811371d86ecda3895157e07b9577e5f34d4d445e9b41438469c7e4ab40a05e" + "sha256": "6a77117d95deeb3be9923632264202ba0979bb7b19496555367ebf10a2ed9369" }, { "location": "prepared", "filename": "world-systems/wasp-59.json", "bytes": 694, - "sha256": "ff85ad8adc74ca1e25ebfbfba21f096411899178aa102db51e19b99e65f0a9cb" + "sha256": "ccc8e42dfbcc3bb834cb1fbbe20b70ba9fc8e06bb51830511aa8c9ab5e97e1c4" }, { "location": "prepared", "filename": "world-systems/wasp-6.json", "bytes": 689, - "sha256": "035fcbbbb4d1670383626ea3c5e01e9b5c9ca24bde5d7aabbb3768ff7dbb8459" + "sha256": "f4ae61f25951081a4056889b2e27b85ed1632e6145c48ad5e92f682c4ea0cddb" }, { "location": "prepared", "filename": "world-systems/wasp-62.json", "bytes": 693, - "sha256": "e9f59ddd33e7770c0902c8eb53b523f06e3577af69920c5b8405b184a3f0ede1" + "sha256": "49145b3636d258522b3b614bfe35e433511230f2935bfcdc9031e40cfeeb56f0" }, { "location": "prepared", "filename": "world-systems/wasp-67.json", "bytes": 695, - "sha256": "1336f37fccaf63c701bcaea06c8bb0af84c7259cd3b3d8539891d7d563e03d2b" + "sha256": "0d5dccd739840d8d47142ce71c56f914d6bc46ea23c1e2944fcf5e75ea93c6fc" }, { "location": "prepared", "filename": "world-systems/wasp-69.json", "bytes": 695, - "sha256": "398e95f151571b983c9ebb036bb8c1f1849bbf7b966d0ee0cca257202d16c181" + "sha256": "3502703401ec016a925f3b3f9ad4b6ccd5befed465ab98c70d7a9b19041b3fcf" }, { "location": "prepared", "filename": "world-systems/wasp-7.json", "bytes": 691, - "sha256": "f33c6c0f3f25abf258c6adf4e7229ce3a225ad8b1d0efe4c7da808c7a3fe08ad" + "sha256": "b6216e581ce47ab817e02bf0818dd1aa3ebd2a120cc1a0d5099718437565d8d9" }, { "location": "prepared", "filename": "world-systems/wasp-74.json", "bytes": 693, - "sha256": "bcc6d8c99c507f5db8aa9e8f3899bfb6e947984ed8e67a2cb00005ed28cab0ec" + "sha256": "b6f3d4ce8997aab236b5ac2d9a358bc345d10a6c9dd0a32ff39455b97afbeb53" }, { "location": "prepared", "filename": "world-systems/wasp-76.json", "bytes": 716, - "sha256": "0ff994127a3ea610b4374f9c21e859ee7457061d1b26e859d4d5755f2301d43c" + "sha256": "9d3b52a8c2a973f55846526684b76bb3e79c968f6ccd9b5ed34a39e94cb262c4" }, { "location": "prepared", "filename": "world-systems/wasp-77-a.json", "bytes": 705, - "sha256": "fa6e02e949dc7a14ace71853e85a90771ed32f2a600a7d8b717ac121a6d2e696" + "sha256": "33b7ddb2205a284c545c2feded02be634cac0376fac1ec1696698c9bb6225229" }, { "location": "prepared", "filename": "world-systems/wasp-8.json", "bytes": 688, - "sha256": "934be90255f5a4b2d072cf7ef2705e4b702140bc6a1c46f4f00bba96c7ad2635" + "sha256": "b5bd31eca00661a04e6a71b470a4aa728821dadf1b29bd5d8426997404634e25" }, { "location": "prepared", "filename": "world-systems/wasp-80.json", "bytes": 691, - "sha256": "b5dc04acaa9e0c9faadd207f8bd938ede908cf18eea0d33419750fa6e3928ac9" + "sha256": "77b2d779e8d70269b9346fdeccc0f3628626bf3d165fd2e946b7dc050311f48f" }, { "location": "prepared", "filename": "world-systems/wasp-84.json", "bytes": 694, - "sha256": "213a24ce0dc746f7c19ae69fff36faa46ec2d0c5ea688cafdfa9377d9543cff7" + "sha256": "3a735d6f91845931c013c65f0217f23d2394693ab40118bd3419872a22a547a1" }, { "location": "prepared", "filename": "world-systems/wasp-85-a.json", "bytes": 703, - "sha256": "689069a44d52f0859defed500a626c95e0e7efc5c9529e09c4465b7f50c7ca87" + "sha256": "be5a57bd4555b59b1fd23aebb0a698fa9ad893663d14d68bc7d83d7abacce4a3" }, { "location": "prepared", "filename": "world-systems/wasp-91.json", "bytes": 693, - "sha256": "b32cec5dd9544a3b9fb153594d5a30370e6efbae80178c708f213a2d435101d5" + "sha256": "ba1652e0d77cde9474e53651c49b1324ca1f60a0bd0873579f38caec84a31a3a" }, { "location": "prepared", "filename": "world-systems/wasp-95.json", "bytes": 697, - "sha256": "c4e3fba2adf3d851dd72f4111218efcb572db270cc03b07f6d8ea6dc12307270" + "sha256": "c487a55eeb7f9b3194223db63b4d7e7d7ae25d3f7276cd60130103a8f83f18fd" }, { "location": "prepared", "filename": "world-systems/wasp-97.json", "bytes": 695, - "sha256": "2b9909a342ed3dc0c3c6ddf00b45526aed4d5a620be6e62705d589eaedf1c2de" + "sha256": "bf18bfe08e45afd254ef06f3193c4620f0ac7b91eaa36be19d595dd07e0f84b6" }, { "location": "prepared", "filename": "world-systems/wasp-99.json", "bytes": 696, - "sha256": "40559a522c5ee05a2df3094f897be1aabfdc8cbe6b1de6d8f68477df5ff97393" + "sha256": "f410497ffcf50e6b533b97ebe2e92992431fbd8baf2307941647a8becca2b5d9" }, { "location": "prepared", "filename": "world-systems/wd-1856-534.json", "bytes": 713, - "sha256": "376577251e7cc9dbd56a175206f0102894776ae604c9bdc3e7c6673de00e672e" + "sha256": "bf49c19b6d1d27930914a1dcc6cc4e54f1a7db6389d828555c4deb3529a05906" }, { "location": "prepared", "filename": "world-systems/wolf-327.json", "bytes": 694, - "sha256": "6b86488492a50b119f3bd36da6cfd91693ededdb93cb5fd69af9be971701ba4e" + "sha256": "f50e95a842cea879d474d5709a9cb4c81f0507e872ab18951ff73bcd779fc3af" }, { "location": "prepared", "filename": "world-systems/wolf-503.json", "bytes": 701, - "sha256": "91d9d2e9842b171b0916beb55931d2876f1119ba278aea8b73a3df7d18653441" + "sha256": "e1aeb71c751bb51368b6f378ee978a4890db05c38b944c464465b0f531ef129e" }, { "location": "prepared", "filename": "world-systems/xo-1.json", "bytes": 679, - "sha256": "0b595ed7f3d6d5be0883c52596acebc45cc5067995c603ab9ae0524a89679de7" + "sha256": "ba314ba49e98b7ef8e49a51593a1aa9364e2ef65f8d7144e855649c0e8a69616" }, { "location": "prepared", "filename": "world-systems/xo-2-n.json", "bytes": 691, - "sha256": "a0349a0e54f88c4354caee94366749cd4fed3ce0ace6708c8ebbec651d7cd5fd" + "sha256": "07285fba4f2db747f10202a2928a386d8a365252eea5d7a001a7d627a22ae30a" }, { "location": "prepared", "filename": "world-systems/yses-1.json", "bytes": 698, - "sha256": "363a7c976f3fe01ea33ad070f99d1f6414753f54b2acfedefc6da9649e6a40f3" + "sha256": "1117f572299df113b4adbc70247a48ec729ce8ce8de46c095b5f50827b45bc5a" } ] } From b6f248a6858f5b2a3dfdb9ed68577f54d092f0e1 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 10:07:05 -0300 Subject: [PATCH 10/23] refactor: a hosted package's card reads its own descriptor, never a catalogue row --- docs/navigation-identity.md | 38 ++++---- packages/bake/AGENTS.md | 4 +- .../src/navigation/navigation-destinations.ts | 29 +----- packages/objects/AGENTS.md | 2 +- packages/objects/src/image-layer-bank.ts | 2 +- packages/objects/src/registry/index.ts | 2 + .../objects/src/registry/object-catalog.ts | 2 +- .../objects/src/registry/object-facts.test.ts | 21 +++++ packages/objects/src/registry/object-facts.ts | 35 ++++++++ .../src/registry/prepared-focus-object.ts | 1 + .../src/universe/prepared-galaxy-catalog.ts | 4 +- .../src/universe/prepared-universe-runtime.ts | 1 - site/application-world-context.mts | 7 +- site/application-world-resources.mts | 2 - .../prepare/prepare-world-presentation.mts | 26 +++--- site/components/HostedObjectFacts.astro | 15 ++++ site/components/ObjectShell.astro | 4 - site/components/PreparedClusterFacts.astro | 14 --- site/focus-catalog-data.mts | 4 - site/focus-catalog.mts | 45 +--------- site/focus-response.mts | 27 +++--- site/object-shell.css | 14 +++ site/overview-context.mts | 3 +- site/pages/focus-fragment/index.astro | 3 +- site/prepared-context-navigation.mts | 23 ++--- site/prepared-focus-card.mts | 89 +++++++------------ site/prepared-focus-target.mts | 26 +++--- site/prepared-focus.mts | 32 ++----- site/scene/scene-router.mts | 6 +- site/scene/scene-selection.mts | 17 ++-- site/search/prepared-catalogue-index.mts | 4 +- site/shell/object-shell-client.mts | 10 +-- site/source-documentation.mts | 6 -- site/test/focus-catalog.test.mts | 35 -------- site/test/navigation-ontology.test.mts | 4 +- .../test/prepared-context-navigation.test.mts | 78 +++++++--------- site/test/prepared-focus-card.test.mts | 89 +++++++------------ site/view-readout.mts | 3 +- src/objects/fornax-cluster/object.json | 51 ++++++++++- src/objects/helix/object.json | 33 ++++++- src/objects/lmc/object.json | 41 ++++++++- src/objects/local-group/object.json | 34 ++++++- src/objects/m1/object.json | 33 ++++++- src/objects/m2-9/object.json | 33 ++++++- src/objects/m31/object.json | 32 ++++++- src/objects/m33/object.json | 32 ++++++- src/objects/m42/object.json | 33 ++++++- src/objects/m45/object.json | 33 ++++++- src/objects/m8/object.json | 33 ++++++- src/objects/m81/object.json | 23 ++++- src/objects/m83/object.json | 22 ++++- src/objects/m87/object.json | 33 ++++++- src/objects/ngc-253/object.json | 23 ++++- src/objects/ngc-300/object.json | 42 ++++++++- src/objects/omega-centauri/object.json | 33 ++++++- src/objects/smc/object.json | 42 ++++++++- src/objects/virgo-cluster/object.json | 51 ++++++++++- 57 files changed, 936 insertions(+), 448 deletions(-) create mode 100644 packages/objects/src/registry/object-facts.test.ts create mode 100644 packages/objects/src/registry/object-facts.ts create mode 100644 site/components/HostedObjectFacts.astro delete mode 100644 site/components/PreparedClusterFacts.astro delete mode 100644 site/test/focus-catalog.test.mts diff --git a/docs/navigation-identity.md b/docs/navigation-identity.md index 358206b62c9..3178c7af351 100644 --- a/docs/navigation-identity.md +++ b/docs/navigation-identity.md @@ -13,27 +13,33 @@ nebula catalogues. Search, aliases and classification tabs use this inventory. | 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 +writes them. An object with a scene of its own mounts it. An object without one +(a galaxy, a cluster of galaxies or a nebula with a package; a level of the zoom +ladder) is drawn by a mounted scene: opened cold, its page mounts the world host, +the Sun. In place, a hosted package keeps the mounted scene; a level 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`. +`OBJECTS` has one entry shape and no kinds. Each entry is built from its package's +own descriptor, and what it can do is what the descriptor declares: a catalogue +block and world frame place it in the world (`isPlacedObject`), a scene type gives +it a scene (`isSceneObject`, which `SCENE_OBJECTS` filters by), and +`properties.overview` makes it a level of the zoom ladder (`isOverviewObject`). +The Local Group is placed and a level; the observable universe is a level only. +`requireSceneObject` rejects an object without a scene when a caller needs a +scene loader. A hosted package states its card's facts under `properties.facts`, +each with its source. + +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 +`sceneHostId` identifies the scene that draws an object without one of its own. 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 diff --git a/packages/bake/AGENTS.md b/packages/bake/AGENTS.md index 398412b16e1..416c04fe5f0 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/src/navigation/navigation-destinations.ts b/packages/bake/src/navigation/navigation-destinations.ts index 725e8f0ad9a..33c795ae12d 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 { parsePreparedGalaxyCatalog } from '@cssearth/catalog'; -import { parsePreparedClusterCatalog } from '@cssearth/catalog'; -import { parsePreparedNebulaCatalog } from '@cssearth/catalog'; -import type { PreparedCatalogObject } from '@cssearth/catalog'; import { isHostedDescriptor, parseNavigationDistance } from '@cssearth/objects'; -import { isRecord, hasErrorCode } from '@cssearth/core'; +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. */ @@ -28,24 +22,3 @@ export function prepareSceneDistance(descriptor: unknown) { 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 }); } - -/** 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; -} diff --git a/packages/objects/AGENTS.md b/packages/objects/AGENTS.md index 210c2f75125..4da44db8bdf 100644 --- a/packages/objects/AGENTS.md +++ b/packages/objects/AGENTS.md @@ -14,7 +14,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/image-layer-bank.ts b/packages/objects/src/image-layer-bank.ts index eef8bc605cc..95e29830030 100644 --- a/packages/objects/src/image-layer-bank.ts +++ b/packages/objects/src/image-layer-bank.ts @@ -17,7 +17,7 @@ export function parseImageLayerBankDescriptor(input: unknown): ImageLayerBankDes if (descriptor.type !== 'image-layer-bank') throw new TypeError('Object is not an image-layer bank.'); const properties = descriptor.properties; // `catalog` and `worldFrame` are the package's registry entry and placement (prepared-focus-object.ts), read by the registry. - const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'catalog', 'worldFrame'].includes(key)); + const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'catalog', 'worldFrame', 'facts'].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) { diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index 5e43db48cf2..b8b35f83851 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -11,6 +11,8 @@ export { distanceDescription, parseNavigationDistance } from './navigation-dista export type { NavigationDistance } from './navigation-distance.js'; export { catalogEntry, catalogueObject } from './object-catalog.js'; export type { CatalogContext, CatalogEntry } from './object-catalog.js'; +export { objectFacts } from './object-facts.js'; +export type { ObjectFact } from './object-facts.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'; diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index 3d6803a254e..e856b27b63e 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -96,6 +96,6 @@ export function catalogueObject(value: unknown, return Object.freeze({ id, name, description, route: `/${id}/`, sceneHostId: value.sceneHostId as string, ...(entry ? { classification: entry.classification, ...(entry.classificationLabel === undefined ? {} : { classificationLabel: entry.classificationLabel }), systemName: entry.systemName, color: entry.color, distance: entry.distance, worldFrame: entry.worldFrame, discovery: entry.discovery, - searchNames: Object.freeze(destinationSearchNames([id, entry.name, ...entry.aliases])) } : {}), + aliases: entry.aliases, searchNames: Object.freeze(destinationSearchNames([id, entry.name, ...entry.aliases])) } : {}), ...ladder }); } diff --git a/packages/objects/src/registry/object-facts.test.ts b/packages/objects/src/registry/object-facts.test.ts new file mode 100644 index 00000000000..2ceeaf4839d --- /dev/null +++ b/packages/objects/src/registry/object-facts.test.ts @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { objectFacts } from './object-facts.js'; + +const source = { label: 'A paper (2020)', url: 'https://example.org/paper' }; +const descriptor = (facts: unknown) => ({ schema: 'cssearth-object@2', id: 'm31', type: 'image-layer-bank', properties: { facts } }); + +test('reads the facts a package states, each with its source', () => { + assert.deepEqual(objectFacts(descriptor([{ id: 'distance', label: 'Distance', value: '776 kpc', note: 'RR Lyrae.', source }])), + [{ id: 'distance', label: 'Distance', value: '776 kpc', note: 'RR Lyrae.', source }]); + assert.deepEqual(objectFacts({ schema: 'cssearth-object@2', id: 'earth', properties: {} }), []); +}); + +test('refuses a fact without a source, a repeated fact and a field it does not know, naming the file', () => { + const fact = { id: 'distance', label: 'Distance', value: '776 kpc', source }; + assert.throws(() => objectFacts(descriptor([{ ...fact, source: undefined }])), /src\/objects\/m31\/object\.json: properties\.facts\[0\]\.source needs a label and an https url/); + assert.throws(() => objectFacts(descriptor([{ ...fact, source: { ...source, url: 'http://example.org' } }])), /https url/); + assert.throws(() => objectFacts(descriptor([fact, fact])), /properties\.facts\[1\] repeats the fact distance/); + assert.throws(() => objectFacts(descriptor([{ ...fact, unit: 'kpc' }])), /needs an id, a label and a value/); + assert.throws(() => objectFacts(descriptor({})), /is a list of facts/); +}); diff --git a/packages/objects/src/registry/object-facts.ts b/packages/objects/src/registry/object-facts.ts new file mode 100644 index 00000000000..89b8e6f95a0 --- /dev/null +++ b/packages/objects/src/registry/object-facts.ts @@ -0,0 +1,35 @@ +import { isRecord } from '@cssearth/core'; + +/** A fact a package states about itself for its card, with the publication it comes from. */ +export interface ObjectFact { + readonly id: string; + readonly label: string; + readonly value: string; + /** How the value was obtained or what it does not mean, in the source's terms. */ + readonly note?: string; + readonly source: { readonly label: string; readonly url: string }; +} + +/** The facts a package's descriptor states under `properties.facts`, for a package without a content recipe of its own (a + * galaxy, a cluster of galaxies, a nebula the world's host draws); empty when it states none. Each names its source. */ +export function objectFacts(descriptor: unknown): readonly ObjectFact[] { + if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.facts === undefined) return Object.freeze([]); + const id = String(descriptor.id), facts = descriptor.properties.facts; + if (!Array.isArray(facts)) throw new TypeError(`src/objects/${id}/object.json: properties.facts is a list of facts.`); + const seen = new Set(); + return Object.freeze(facts.map((fact: unknown, index) => { + const at = `src/objects/${id}/object.json: properties.facts[${index}]`; + if (!isRecord(fact) || Object.keys(fact).some(key => !['id', 'label', 'value', 'note', 'source'].includes(key)) + || typeof fact.id !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(fact.id) || typeof fact.label !== 'string' || !fact.label.trim() + || typeof fact.value !== 'string' || !fact.value.trim() || (fact.note !== undefined && (typeof fact.note !== 'string' || !fact.note.trim()))) { + throw new TypeError(`${at} needs an id, a label and a value, with an optional note and its source.`); + } + if (seen.has(fact.id)) throw new TypeError(`${at} repeats the fact ${fact.id}.`); + seen.add(fact.id); + const source = fact.source; + if (!isRecord(source) || Object.keys(source).some(key => key !== 'label' && key !== 'url') || typeof source.label !== 'string' || !source.label.trim() + || typeof source.url !== 'string' || !/^https:\/\//u.test(source.url)) throw new TypeError(`${at}.source needs a label and an https url; a fact without a source is not stated.`); + return Object.freeze({ id: fact.id, label: fact.label, value: fact.value, ...(fact.note === undefined ? {} : { note: fact.note as string }), + source: Object.freeze({ label: source.label, url: source.url }) }); + })); +} diff --git a/packages/objects/src/registry/prepared-focus-object.ts b/packages/objects/src/registry/prepared-focus-object.ts index 4087c48a87f..7b189029ff8 100644 --- a/packages/objects/src/registry/prepared-focus-object.ts +++ b/packages/objects/src/registry/prepared-focus-object.ts @@ -42,6 +42,7 @@ export interface NavigableObject { export interface PreparedFocusObject { readonly id: string; readonly name: string; + readonly aliases: readonly string[]; readonly searchNames: readonly string[]; readonly classification: ObjectClassification; readonly classificationLabel?: string; diff --git a/packages/renderer/src/universe/prepared-galaxy-catalog.ts b/packages/renderer/src/universe/prepared-galaxy-catalog.ts index a03acf343f6..2af3c276c4c 100644 --- a/packages/renderer/src/universe/prepared-galaxy-catalog.ts +++ b/packages/renderer/src/universe/prepared-galaxy-catalog.ts @@ -1,4 +1,4 @@ -import { isPreparedCluster, parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; +import { parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; import type { PreparedCatalogObject } from '@cssearth/catalog'; import type { WorldCameraPose, WorldCameraViewport } from '../navigation/world-camera.js'; import { createOpacityFader } from '../stars/opacity-fader.js'; @@ -38,8 +38,6 @@ export function mountPreparedGalaxyCatalog({ host, before, payload, clusters, ga }); let destroyed = false, dormant = false; return Object.freeze({ root, catalog, - /** The catalogue row `id` names, as data for whoever presents it. */ - 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) { if (destroyed) return; if (world.referenceFrame !== catalog.frame.referenceFrame || world.epochJdTt !== catalog.frame.epochJdTt) { diff --git a/packages/renderer/src/universe/prepared-universe-runtime.ts b/packages/renderer/src/universe/prepared-universe-runtime.ts index b7fef116d9b..ab474a73d69 100644 --- a/packages/renderer/src/universe/prepared-universe-runtime.ts +++ b/packages/renderer/src/universe/prepared-universe-runtime.ts @@ -268,7 +268,6 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol } if (pointsChanged || changed || bodyChanged) 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, diff --git a/site/application-world-context.mts b/site/application-world-context.mts index a0996f0dc84..5a1db9f0344 100644 --- a/site/application-world-context.mts +++ b/site/application-world-context.mts @@ -1,3 +1,8 @@ +import { isHostedObject } from '@cssearth/objects'; +import { knownObject, loadObject } from './object-directory.mts'; +import type { NavigableObject } from './objects.mts'; +/** The registry object a focus is on: a placed object the world's host draws. */ +const hostedFocus = (object: NavigableObject | null | undefined) => object != null && isHostedObject(object) ? object : null; import { createSceneLifetime } from '@cssearth/engine'; import { labelOcclusionFor } from '@cssearth/renderer'; import { afterStartup } from '@cssearth/renderer/rendering/startup-gate.ts'; @@ -57,7 +62,7 @@ export function createApplicationWorldContext() { 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 })); + objects: { known: id => hostedFocus(knownObject(id)), load: id => loadObject(id).then(hostedFocus) }, 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 diff --git a/site/application-world-resources.mts b/site/application-world-resources.mts index 2ca483eecf1..fe87ee0e88e 100644 --- a/site/application-world-resources.mts +++ b/site/application-world-resources.mts @@ -43,7 +43,6 @@ const VOLUME_CATALOGUE_POINTS: Readonly> = { // 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 { @@ -188,7 +187,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/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/components/HostedObjectFacts.astro b/site/components/HostedObjectFacts.astro new file mode 100644 index 00000000000..4adc16f0a7d --- /dev/null +++ b/site/components/HostedObjectFacts.astro @@ -0,0 +1,15 @@ +--- +import { objectFacts } from '@cssearth/objects'; +import { CONTEXT_OBJECT_DESCRIPTORS } from '../prepared-context-objects.mts'; +import ObjectFactsheet from './ObjectFactsheet.astro'; +// Each package the world's host draws states its own facts (object.json `properties.facts`), each with its source. +const packages = Object.values(CONTEXT_OBJECT_DESCRIPTORS).map(descriptor => ({ id: (descriptor as { id: string }).id, facts: objectFacts(descriptor) })) + .filter(({ facts }) => facts.length > 0).sort((a, b) => a.id.localeCompare(b.id, 'en')); +--- +{packages.map(({ id, facts }) =>

)} diff --git a/site/components/ObjectShell.astro b/site/components/ObjectShell.astro index e6a5bada599..b651933c64a 100644 --- a/site/components/ObjectShell.astro +++ b/site/components/ObjectShell.astro @@ -1,7 +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"; @@ -255,11 +254,8 @@ import Detached from './Detached.astro'; data-information-group="prepared-focus" role="region" aria-label="Factsheet">
    - {[['distance', 'Observer distance'], ['uncertainty', 'Distance uncertainty'], ['membership', 'Membership'], ['association', 'Association']].map(([id, label]) => -
  • {label}
  • )}
- {OVERVIEWS.map(overview => )} 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/focus-catalog-data.mts b/site/focus-catalog-data.mts index f869d7b1ad5..fbd42d64d81 100644 --- a/site/focus-catalog-data.mts +++ b/site/focus-catalog-data.mts @@ -3,7 +3,6 @@ import { parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; import galaxies from '../src/objects/local-group/prepared/catalogue.json' with { type: 'json' }; import clusters from '../src/objects/galaxy-clusters/prepared/catalogue.json' with { type: 'json' }; -import { focusSourceDocumentation } from './source-documentation.mts'; const parts = Object.values(import.meta.glob('../src/objects/*/source/nebula.json', { eager: true, import: 'default' })).map(parsePreparedNebulaCatalog); const nebulae = parsePreparedNebulaCatalog({ schema: 'cssearth-nebula-catalog@1', frame: galaxies.frame, @@ -16,6 +15,3 @@ export const FOCUS_CATALOG_DATA = [ ].map(({ id, data }) => { return { id, data, text: JSON.stringify(data) }; }); - -export const FOCUS_SOURCE_DOCUMENTS = new Map(FOCUS_CATALOG_DATA.flatMap(catalog => catalog.data.objects - .map(object => [object.id, focusSourceDocumentation(object, catalog.id)] as const))); diff --git a/site/focus-catalog.mts b/site/focus-catalog.mts index ee8ff38d9e3..c820bbed10c 100644 --- a/site/focus-catalog.mts +++ b/site/focus-catalog.mts @@ -1,49 +1,6 @@ import { parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; -import { isRecord } from '@cssearth/core'; -import type { PreparedCatalogObject, PreparedGalaxyCatalog, PreparedClusterCatalog, PreparedNebulaCatalog } from '@cssearth/catalog'; -type PreparedFocusCatalog = PreparedGalaxyCatalog | PreparedClusterCatalog | PreparedNebulaCatalog; - -/** Keep the selected record and its complete physical-host chain in a native-navigation fragment. */ -export function initialFocusCatalog(catalog: PreparedFocusCatalog, selected: PreparedCatalogObject): PreparedFocusCatalog { - if (!catalog.objects.some(object => object === selected || object.id === selected.id)) throw new TypeError('Initial focus is outside its catalogue.'); - if (catalog.schema !== 'cssearth-galaxy-catalog@1') { - const value = { ...catalog, objects: [selected] }; - return catalog.schema === 'cssearth-cluster-catalog@1' ? parsePreparedClusterCatalog(value) : parsePreparedNebulaCatalog(value); - } - const positioned = new Map(catalog.objects.map(object => [object.id, object])); - const unpositioned = new Map((catalog.unpositionedHosts ?? []).map(object => [object.id, object])); - const included = new Set(); - let current: { readonly id: string; readonly hostId?: string } | undefined = positioned.get(selected.id); - while (current) { - if (included.has(current.id)) throw new TypeError(`Cyclic physical host: ${current.id}.`); - included.add(current.id); - if (current.hostId === undefined) break; - const hostId = current.hostId; - current = positioned.get(hostId) ?? unpositioned.get(hostId); - if (!current) throw new TypeError(`Unknown physical host: ${hostId}.`); - } - return parsePreparedGalaxyCatalog({ ...catalog, - objects: catalog.objects.filter(object => included.has(object.id)), - unpositionedHosts: (catalog.unpositionedHosts ?? []).filter(object => included.has(object.id)), - }); -} - -export function readInitialFocus(document: Document): PreparedCatalogObject | null { - const script = document.querySelector('script[data-initial-focus]'); - if (!script) return null; - const selectedId = script.getAttribute('data-initial-focus'); - if (!selectedId || !/^[a-z0-9][a-z0-9:._+-]{0,127}$/iu.test(selectedId)) throw new TypeError('Initial focus identity is invalid.'); - const value: unknown = JSON.parse(script.textContent ?? ''); - if (!isRecord(value)) throw new TypeError('Initial focus is invalid.'); - const catalog = value.schema === 'cssearth-nebula-catalog@1' ? parsePreparedNebulaCatalog(value) - : value.schema === 'cssearth-cluster-catalog@1' ? parsePreparedClusterCatalog(value) : parsePreparedGalaxyCatalog(value); - const selected = catalog.objects.find(object => object.id === selectedId); - if (!selected) throw new TypeError('Initial focus identity names no prepared record.'); - return selected; -} - -/** The three prepared focus catalogues, served at `/catalogues/.json` (`site/pages/catalogues/[id].json.ts`). */ +/** The three spatial catalogues the world draws as dots, served at `/catalogues/.json` (`site/pages/catalogues/[id].json.ts`). */ export async function loadFocusCatalogs(origin: string, fetcher: typeof fetch = fetch, signal?: AbortSignal) { const rows = await Promise.all((['galaxies', 'clusters', 'nebulae'] as const).map(async id => { const url = new URL(`/catalogues/${id}.json`, origin); diff --git a/site/focus-response.mts b/site/focus-response.mts index 8c3378f07c0..e0b07703bc4 100644 --- a/site/focus-response.mts +++ b/site/focus-response.mts @@ -1,18 +1,17 @@ import { syncTabPanels } from './tab-panels.mts'; import { parseHTML } from 'linkedom'; -import { parseObjectDescriptor } from '@cssearth/objects'; +import { catalogueObject, isHostedObject, parseObjectDescriptor } from '@cssearth/objects'; import { sectionElements } from '@cssearth/renderer'; import { loadPreparedVolumeDatasets, createPreparedVolumeDatasets, imageFocusDatasets } from '@cssearth/renderer/universe'; import { worldCameraFromCenteredPresentation, presentWorldCamera, createWorldSelectionTarget, savedWorldCamera } from '@cssearth/renderer/navigation'; import type { PreparedWorldCameraFrame, SharedView } from '@cssearth/renderer/navigation'; import type { ObjectRuntimeDefinition } from '@cssearth/renderer/runtime/object-runtime-types.ts'; -import { initialFocusCatalog, loadFocusCatalogs } from './focus-catalog.mts'; import { requiredElement } from './browser/browser-types.mts'; import { isRecord } from '@cssearth/core'; import { createPreparedFocusCard } from './prepared-focus-card.mts'; import { fetchFocusFragment, focusBanksPending, spliceFocusBanks } from './focus-fragment.mts'; import { readPreparedFocusSelection } from './navigation/navigation-scope.mts'; -import { preparedFocusObjectId, resolvePreparedFocus, preparedFocusCitations, resolvePreparedFocusDataset } from './prepared-focus.mts'; +import { resolvePreparedFocus, resolvePreparedFocusDataset } from './prepared-focus.mts'; import type { PreparedFocusPresentation } from './prepared-focus.mts'; import { PREPARED_WORLD_PRESENTATION } from './prepared-world-presentation.mts'; @@ -22,18 +21,19 @@ export async function renderNativeFocus(shell: Document, stage: HTMLElement, url frame: PreparedWorldCameraFrame, saved: SharedView | null, fetcher: typeof fetch): Promise { const selection = readPreparedFocusSelection(url, definition.id); if (!selection) return saved; - const catalogs = await loadFocusCatalogs(url.origin, fetcher); - const catalog = [catalogs.galaxies, catalogs.clusters, catalogs.nebulae].find(catalog => catalog.objects.some(record => record.id === selection.id)); - const selected = catalog?.objects.find(record => record.id === selection.id); - if (!catalog || !selected) throw new RangeError('Prepared focus is unavailable.'); - const objectId = preparedFocusObjectId(selected); + // The object's own registry entry, as every page reads it. + const entry = await fetcher(new URL(`/objects/${selection.id}/entry.json`, url.origin), { redirect: 'error', signal: AbortSignal.timeout(15_000) }); + if (!entry.ok) throw new RangeError('Prepared focus is unavailable.'); + const selected = catalogueObject(await entry.json() as unknown, () => async () => { throw new Error('A focus has no scene of its own.'); }); + if (!isHostedObject(selected)) throw new RangeError('Prepared focus is unavailable.'); + const objectId = selected.id; const root = sectionElements(shell, '[data-prepared-focus-card]')[0]; if (!root) throw new Error('Prepared focus card is missing.'); if (focusBanksPending(root)) { const html = await fetchFocusFragment(path => fetcher(new URL(path, url.origin), { redirect: 'error', signal: AbortSignal.timeout(15_000) })); spliceFocusBanks(root, parseHTML(html).document); } - const unavailable = objectId !== undefined && (root.querySelector('[data-focus-unavailable]')?.dataset.unavailableObjects?.split(' ') ?? []).includes(objectId); + const unavailable = (root.querySelector('[data-focus-unavailable]')?.dataset.unavailableObjects?.split(' ') ?? []).includes(objectId); const bank = unavailable ? undefined : sectionElements(root, '[data-focus-dataset-bank]').find(bank => bank.dataset.focusDatasetBank === objectId); let presentation: PreparedFocusPresentation | undefined; if (bank) { @@ -58,8 +58,7 @@ export async function renderNativeFocus(shell: Document, stage: HTMLElement, url return response.arrayBuffer(); } }); const datasetId = resolvePreparedFocusDataset(selection.dataset, payload)!; - const focus = resolvePreparedFocus(selected, payload.framingRadiusUnits * payload.datasets[0].volume.frame.metersPerUnit, - PREPARED_WORLD_PRESENTATION.galaxies); + const focus = resolvePreparedFocus(selected, PREPARED_WORLD_PRESENTATION.galaxies); const focalCss = definition.camera.projection.cssPerspective; const viewport = { focalPixels: 1000, widthPixels: 1e9, heightPixels: 1e9, principalOffsetPixels: [0, 0] as const }; const world = saved ? savedWorldCamera(saved, frame, viewport) : createWorldSelectionTarget( @@ -84,12 +83,8 @@ export async function renderNativeFocus(shell: Document, stage: HTMLElement, url const card = createPreparedFocusCard(root, id => { for (const radio of root.querySelectorAll(':scope > .object-native-tabs > input')) radio.toggleAttribute('checked', radio.value === id); }); - card.set(selected, preparedFocusCitations(selected, catalog.sources), presentation); + card.set(selected, presentation); card.destroy(); syncTabPanels(root); - // A page the build already rendered its focus into answers a query again: one initial record, the latest. - for (const previous of root.querySelectorAll(':scope > script[data-initial-focus]')) previous.remove(); - const initial = shell.createElement('script'); initial.type = 'application/json'; initial.dataset.initialFocus = selected.id; - initial.textContent = JSON.stringify(initialFocusCatalog(catalog, selected)).replace(/ .object-panel-summary .object-panel-icon { font: var(--shell-font-introduction); user-select: text; } +/* The publications a hosted package's facts come from, under its factsheet (HostedObjectFacts.astro). */ +.object-fact-sources { + display: grid; + gap: 6px; + margin: 12px 0 0; + padding: 0; + list-style: none; + font: var(--shell-font-small); +} +.object-fact-sources a { + color: var(--shell-text-secondary); + overflow-wrap: anywhere; + text-underline-offset: 2px; +} .object-learn-more-link { display: inline-block; width: fit-content; diff --git a/site/overview-context.mts b/site/overview-context.mts index da03c66ed9c..dc17af800a0 100644 --- a/site/overview-context.mts +++ b/site/overview-context.mts @@ -1,6 +1,5 @@ import { SYSTEM_FRAMING_RADII, systemOverviewDistance } from './system-framing.mts'; import type { ObjectWorldNavigation } from '@cssearth/renderer/runtime/world-navigation-types.ts'; -import type { PreparedCatalogObject } from '@cssearth/catalog'; import type { WorldCameraPose, PreparedWorldCameraFrame } from '@cssearth/renderer/navigation/world-camera.ts'; /** `system` is the planetary system of the mounted star, and every other scope is an overview's id (its registry entry, * KNOWN_OVERVIEWS); every scope is measured from that star (overviewScopeAtCamera). */ @@ -95,7 +94,7 @@ export function overviewScopeAtCamera(world: WorldCameraPose, previous: Overview return reachable[0]?.id ?? 'system'; } -export function viewDistance(world: WorldCameraPose, frame: PreparedWorldCameraFrame, scope: OverviewScope, plan = context, focus: Pick | null = null) { +export function viewDistance(world: WorldCameraPose, frame: PreparedWorldCameraFrame, scope: OverviewScope, plan = context, focus: { readonly name: string; readonly positionM: readonly number[] } | null = null) { if (focus) return { label: `Distance to ${focus.name}:`, meters: distance(world.pose.positionM, focus.positionM), title: `Camera distance from the prepared center of ${focus.name}` }; return scope !== 'system' ? { diff --git a/site/pages/focus-fragment/index.astro b/site/pages/focus-fragment/index.astro index 3336adc4d74..bc53c22b8bb 100644 --- a/site/pages/focus-fragment/index.astro +++ b/site/pages/focus-fragment/index.astro @@ -1,7 +1,8 @@ --- +import HostedObjectFacts from '../../components/HostedObjectFacts.astro'; // The prepared focus card's dataset banks, rendered once for every object page. // Pages fetch this on their first focus selection (`site/focus-fragment.mts`). import PreparedFocusDatasets from '../../components/PreparedFocusDatasets.astro'; ---
-
+
diff --git a/site/prepared-context-navigation.mts b/site/prepared-context-navigation.mts index 8de9a2dbdcc..5463c03f9dc 100644 --- a/site/prepared-context-navigation.mts +++ b/site/prepared-context-navigation.mts @@ -1,4 +1,3 @@ -import type { PreparedCatalogObject, SpatialCatalogSource, SpatialCitation } from '@cssearth/catalog'; import type { createPreparedUniverse } from '@cssearth/renderer/universe/prepared-universe-runtime.ts'; import type { ObjectWorldNavigation } from '@cssearth/renderer/runtime/world-navigation-types.ts'; import type { PreparedNavigationFocus } from '@cssearth/renderer/navigation/prepared-focus.ts'; @@ -6,12 +5,12 @@ import { presentWorldCamera } from '@cssearth/renderer/navigation'; import { readPreparedFocusSelection } from './navigation/navigation-scope.mts'; import { acquirePreparedFocusTarget } from './prepared-focus-target.mts'; import type { PreparedFocusTarget } from './prepared-focus-target.mts'; -import type { PreparedFocusPolicy, PreparedFocusPresentation } from './prepared-focus.mts'; +import type { FocusObject, PreparedFocusPolicy, PreparedFocusPresentation } from './prepared-focus.mts'; -type PreparedContextLayer = Pick['mount']>, 'resolveGalaxy' | 'ensureGalaxyCatalog' | 'focusBank' | 'selectGalaxy'>; +type PreparedContextLayer = Pick['mount']>, 'focusBank' | 'selectGalaxy'>; export interface FocusPublication { - record: PreparedCatalogObject | null; - sources: readonly SpatialCitation[]; + /** The registry object the focus is on. */ + object: FocusObject | null; presentation: PreparedFocusPresentation | null; url: 'preserve' | 'selection' | 'reframe'; } @@ -31,7 +30,8 @@ export interface FocusOperation { interface ContextNavigationOptions { layer: PreparedContextLayer; presentation: PreparedFocusPolicy; - sources?: readonly SpatialCatalogSource[] | (() => readonly SpatialCatalogSource[]); + /** The registry's object with an id: the one the page already holds, and a read of its entry when it does not. */ + objects: { known(id: string): FocusObject | null; load(id: string): Promise }; windowTarget: Window; onError?(error: unknown): void; unavailableObjectIds?: readonly string[]; @@ -58,14 +58,15 @@ function savedCameraShowsFocus(navigation: ObjectWorldNavigation, focus: Prepare } /** Catalogue focus on the current detailed scene's shared camera owner. */ -export function createPreparedContextNavigation({ layer, presentation, sources = [], windowTarget, onError = console.error, unavailableObjectIds = [] }: ContextNavigationOptions) { +export function createPreparedContextNavigation({ layer, presentation, objects, windowTarget, onError = console.error, unavailableObjectIds = [] }: ContextNavigationOptions) { /** The detailed scene's camera owner this focus is connected to, with that scene's publication callbacks. */ let connection: (FocusCallbacks & { readonly owner: ObjectWorldNavigation; unsubscribe(): void }) | null = null; let target: PreparedFocusTarget | null = null; - const currentSources = () => typeof sources === 'function' ? sources() : sources; const useTarget = (id: string | null) => { if (id === (target?.id ?? null)) return target; - const next = id ? acquirePreparedFocusTarget(id, { layer, policy: presentation, sources: currentSources, unavailableObjectIds, + const object = id ? objects.known(id) : null; + if (id && !object) throw new TypeError(`Unknown object the world's host draws: ${id}`); + const next = object ? acquirePreparedFocusTarget(object, { layer, policy: presentation, unavailableObjectIds, onChange: () => { if (target?.id === id) publishDataset(); }, onError }) : null; const previous = target; target = next; @@ -82,7 +83,7 @@ export function createPreparedContextNavigation({ layer, presentation, sources = const controls = active && state ? { ...state, selectDataset(dataset: string) { if (target === active && owns(active)) active.selectDataset(dataset); }, } : null; - connection?.onFocusChange({ record: active?.record ?? null, sources: active?.citations ?? [], presentation: controls, url }); + connection?.onFocusChange({ object: active?.object ?? null, presentation: controls, url }); }; const publishDataset = () => { if (target && owns(target)) publishContent(); }; const publishSelection = (force = false) => { @@ -141,7 +142,7 @@ export function createPreparedContextNavigation({ layer, presentation, sources = return loading ? loading.then(activate) : activate(); }; try { - const pending = selection && !layer.resolveGalaxy(selection.id) ? layer.ensureGalaxyCatalog().then(prepare) : prepare(); + const pending = selection && !objects.known(selection.id) ? objects.load(selection.id).then(prepare) : prepare(); return pending?.catch(recover); } catch (error) { return recover(error); } } diff --git a/site/prepared-focus-card.mts b/site/prepared-focus-card.mts index ddeb21851d4..d44956b741b 100644 --- a/site/prepared-focus-card.mts +++ b/site/prepared-focus-card.mts @@ -1,33 +1,26 @@ -import { catalogueClassification, isPreparedCluster, isPreparedNebula } from '@cssearth/catalog'; import { publishDatasetPreview, sectionElements, showSection } from '@cssearth/renderer'; -import type { PreparedCatalogObject, SpatialCitation } from '@cssearth/catalog'; -import { preparedFocusObjectId } from './prepared-focus.mts'; -import type { PreparedFocusPresentation } from './prepared-focus.mts'; +import type { FocusObject, PreparedFocusPresentation } from './prepared-focus.mts'; +import { objectTypeLabel } from './object-classification-label.mts'; import { overviewHoldingAt } from './overview-context.mts'; import { wikipediaLearnMoreUrl } from './learn-more.mts'; interface PreparedFocusCard { - set(record: PreparedCatalogObject | null, sources?: readonly SpatialCitation[], presentation?: PreparedFocusPresentation | null): void; - /** Adopt dataset banks spliced in after mount (`focus-fragment.mts`) and present the current record again. */ + set(object: FocusObject | null, presentation?: PreparedFocusPresentation | null): void; + /** Adopt dataset banks spliced in after mount (`focus-fragment.mts`) and present the current object again. */ adoptBanks(): void; destroy(): void; } -const number = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 4 }); -const words = (value: string) => value.replaceAll('-', ' ').replace(/^./u, letter => letter.toUpperCase()); - -/** One retained card transports the selected prepared record; no catalogue is imported here. +/** One retained card presents the selected object from its registry entry; no catalogue is read here. * Its visibility belongs to the selection presentation (`selection-presentation.mts`). */ export function createPreparedFocusCard(root: HTMLElement | null, showTab: (id: string) => void = () => {}): PreparedFocusCard { if (!root) return { set() {}, adoptBanks() {}, destroy() {} }; // The factsheet tab's panel waits in a template while closed (tab-panels.mts): its fields are found there too. const find = (selector: string) => sectionElements(root, selector); - const fields = Object.fromEntries(['name', 'aliases', 'introduction', 'status', 'distance', 'uncertainty', 'membership', 'association'] + const fields = Object.fromEntries(['name', 'aliases', 'introduction', 'status'] .map(name => { const field = find(`[data-focus-${name}]`)[0]; if (!field) throw new Error(`Prepared focus field is missing: ${name}.`); return [name, field]; })); const aliasesRow = find('[data-focus-aliases-row]')[0] ?? null; const learnMore = find('[data-focus-learn-more]')[0] ?? null; - const links = find('[data-focus-source]'); - const sourceRows = find('[data-focus-source-row]'); const breadcrumbs = find('[data-focus-breadcrumb-scope]'); const events = new AbortController(); const unavailable = root.querySelector('[data-focus-unavailable]'); @@ -55,9 +48,9 @@ export function createPreparedFocusCard(root: HTMLElement | null, showTab: (id: }; adopt(); let presented: Parameters | null = null; - const setPresentation = (record: PreparedCatalogObject | null, presentation: PreparedFocusPresentation | null) => { + const setPresentation = (record: FocusObject | null, presentation: PreparedFocusPresentation | null) => { const previous = currentPresentation?.objectId; - currentPresentation = record && preparedFocusObjectId(record) === presentation?.objectId ? presentation : null; + currentPresentation = record && record.id === presentation?.objectId ? presentation : null; if (datasetTab && datasetTab.hidden !== !currentPresentation) datasetTab.hidden = !currentPresentation; if (record && (record.id !== currentRecordId || previous !== currentPresentation?.objectId)) showTab(currentPresentation ? 'dataset' : 'factsheet'); @@ -81,58 +74,38 @@ export function createPreparedFocusCard(root: HTMLElement | null, showTab: (id: } }; const write = (name: string, value: string) => { if (fields[name].textContent !== value) fields[name].textContent = value; }; - const card: PreparedFocusCard = { set(record, sources = [], presentation = null) { - presented = [record, sources, presentation]; - setPresentation(record, presentation); + const card: PreparedFocusCard = { set(object, presentation = null) { + presented = [object, presentation]; + setPresentation(object, presentation); if (unavailable) { - const objectId = record && preparedFocusObjectId(record); - const missing = objectId && unavailableIds.has(objectId); + const missing = object !== null && unavailableIds.has(object.id); if (unavailable.hidden !== !missing) unavailable.hidden = !missing; - const message = missing ? `The 3D view of ${record.name} is unavailable in this installation. Catalogue facts remain available.` : ''; + const message = missing ? `The 3D view of ${object.name} is unavailable in this installation. Its facts remain available.` : ''; if (unavailable.textContent !== message) unavailable.textContent = message; } - if (learnMore && !record) learnMore.hidden = true; - if (!record) return; - root.dataset.preparedFocusId = record.id; - for (const bank of find('[data-focus-record-bank]')) showSection(bank, bank.dataset.focusRecordBank === record.id); - const cluster = isPreparedCluster(record), nebula = isPreparedNebula(record); - const aliases = record.aliases.join(', '); - write('name', record.name); + if (learnMore && !object) learnMore.hidden = true; + if (!object) return; + root.dataset.preparedFocusId = object.id; + // Each object's facts are its package's own, rendered at build time (HostedObjectFacts.astro); only the selected one's is mounted. + for (const bank of find('[data-focus-record-bank]')) showSection(bank, bank.dataset.focusRecordBank === object.id); + const aliases = object.aliases.join(', '); + write('name', object.name); write('aliases', aliases); - write('introduction', nebula ? record.introduction.text : aliases ? `Also known as ${aliases}` : ''); - fields.introduction.hidden = !nebula && !aliases; + write('introduction', object.description); + if (fields.introduction.hidden) fields.introduction.hidden = false; + // A nebula or a globular cluster keeps its classification out of the title; its facts state it. + const galactic = object.classification === 'nebula' || object.classification === 'globular-cluster'; + if (fields.status.hidden !== galactic) fields.status.hidden = galactic; + write('status', galactic ? '' : objectTypeLabel(object)); if (learnMore) { - const href = wikipediaLearnMoreUrl(record.name); + const href = wikipediaLearnMoreUrl(object.name); if (learnMore.getAttribute('href') !== href) learnMore.setAttribute('href', href); - learnMore.hidden = fields.introduction.hidden; + learnMore.hidden = false; } - if (aliasesRow) aliasesRow.hidden = !nebula || !aliases; - // The trail leads to the level that holds the focus's classification (its overview's `holds`) where it lies. - const parent = overviewHoldingAt(catalogueClassification(record), record.positionM); - const parentScope = parent?.id; + if (aliasesRow) aliasesRow.hidden = !aliases; + // The trail leads to the level that holds the object's classification (its overview's `holds`) where it lies. + const parentScope = overviewHoldingAt(object.classification, object.worldFrame.originM)?.id; for (const trail of breadcrumbs) trail.hidden = trail.dataset.focusBreadcrumbScope !== parentScope; - fields.status.hidden = nebula; - write('status', nebula ? '' : cluster ? record.classification.name : `${words(record.status)} galaxy`); - const distanceScale = record.distance.valuePc >= 1e6 ? 1e6 : record.distance.valuePc >= 1e3 ? 1e3 : 1; - write('distance', `${number.format(record.distance.valuePc / distanceScale)} ${distanceScale === 1e6 ? 'Mpc' : distanceScale === 1e3 ? 'kpc' : 'pc'}`); - const associationLabel = find('[data-focus-fact-label=association]')[0] ?? null; - if (associationLabel) associationLabel.textContent = cluster ? 'Redshift' : 'Association'; - const distanceLabel = find('[data-focus-fact-label=distance]')[0] ?? null; - if (distanceLabel) distanceLabel.textContent = cluster ? 'Group distance' : 'Observer distance'; - const { minusPc, plusPc, uncertainty } = record.distance; - write('uncertainty', uncertainty ? `${number.format(uncertainty.statisticalPc)} pc statistical; ${number.format(uncertainty.systematicPc)} pc systematic` - : minusPc !== undefined && plusPc !== undefined ? `−${number.format(minusPc)} / +${number.format(plusPc)} pc` : 'Not supplied'); - if (fields.uncertainty.parentElement) fields.uncertainty.parentElement.hidden = !uncertainty && minusPc === undefined && plusPc === undefined; - if (fields.membership.parentElement) fields.membership.parentElement.hidden = cluster; - write('membership', cluster ? 'Galaxy cluster' : nebula ? parent?.name ?? '' : words(record.membership.group)); - write('association', cluster ? `${record.redshift.value}` : nebula ? record.kind === 'globular-cluster' ? 'Galactic globular cluster' : 'Galactic nebula' : words(record.membership.subgroup)); - for (const [index, link] of links.entries()) { - const source = sources[index]; - link.hidden = !source; - if (sourceRows[index]) sourceRows[index].hidden = !source; - if (source) { link.href = source.url; link.textContent = source.citation; } - else { link.removeAttribute('href'); link.textContent = ''; } - } }, adoptBanks() { adopt(); if (presented) card.set(...presented); }, destroy() { events.abort(); currentPresentation = null; presented = null; } }; return card; } diff --git a/site/prepared-focus-target.mts b/site/prepared-focus-target.mts index bb2e3f45c18..2a07baa20e6 100644 --- a/site/prepared-focus-target.mts +++ b/site/prepared-focus-target.mts @@ -1,30 +1,24 @@ -import type { PreparedCatalogObject, SpatialCatalogSource, SpatialCitation } from '@cssearth/catalog'; import type { PreparedFocusBank } from '@cssearth/renderer/universe/prepared-focus-bank.ts'; -import { preparedFocusObjectId, resolvePreparedFocus, preparedFocusCitations, resolvePreparedFocusDataset } from './prepared-focus.mts'; -import type { PreparedFocusPolicy } from './prepared-focus.mts'; +import { resolvePreparedFocus, resolvePreparedFocusDataset } from './prepared-focus.mts'; +import type { FocusObject, PreparedFocusPolicy } from './prepared-focus.mts'; export interface PreparedFocusSource { - resolveGalaxy(id: string): PreparedCatalogObject | null; focusBank(id: string): PreparedFocusBank | null; } -/** One acquired target owns catalogue identity, framing, datasets and its residency pin. */ -export function acquirePreparedFocusTarget(id: string, { layer, policy, sources, unavailableObjectIds, onChange, onError }: { - layer: PreparedFocusSource; policy: PreparedFocusPolicy; sources(): readonly SpatialCatalogSource[]; +/** One acquired target owns the object's identity, framing, datasets and its residency pin. */ +export function acquirePreparedFocusTarget(object: FocusObject, { layer, policy, unavailableObjectIds, onChange, onError }: { + layer: PreparedFocusSource; policy: PreparedFocusPolicy; unavailableObjectIds: readonly string[]; onChange(): void; onError(error: unknown): void; }) { - const record = layer.resolveGalaxy(id); - if (!record) throw new TypeError(`Unknown prepared galaxy focus: ${id}`); - const objectId = preparedFocusObjectId(record); - const unavailable = objectId !== undefined && unavailableObjectIds.includes(objectId); - const bank = objectId && !unavailable ? layer.focusBank(objectId) : null; - let released = false, citations: readonly SpatialCitation[] | undefined; + const id = object.id, unavailable = unavailableObjectIds.includes(id); + const bank = unavailable ? null : layer.focusBank(id); + let released = false; const unsubscribe = bank?.subscribe(() => { if (!released) onChange(); }); return { - id, record, + id, object, get datasets() { return bank?.state() ?? null; }, - get focus() { return resolvePreparedFocus(record, bank?.framingRadiusM(), policy); }, - get citations() { return citations ??= preparedFocusCitations(record, sources()); }, + get focus() { return resolvePreparedFocus(object, policy); }, prepare({ preload = false }: { preload?: boolean } = {}): Promise | undefined { if (released || !bank || bank.state() && !preload) return; const loading = bank.load(); diff --git a/site/prepared-focus.mts b/site/prepared-focus.mts index a1975646053..9eb68c13283 100644 --- a/site/prepared-focus.mts +++ b/site/prepared-focus.mts @@ -1,5 +1,4 @@ -import { isPreparedCluster, isPreparedNebula, resolveSpatialCitation } from '@cssearth/catalog'; -import type { PreparedCatalogObject, SpatialCatalogSource } from '@cssearth/catalog'; +import type { PreparedFocusObject } from '@cssearth/objects'; import type { PreparedNavigationFocus } from '@cssearth/renderer/navigation/prepared-focus.ts'; import type { PreparedFocusDatasets } from '@cssearth/renderer/universe/prepared-focus-bank.ts'; @@ -7,32 +6,17 @@ export interface PreparedFocusPolicy { metersPerParsec: number; defaultFocusRadiusM: number; minimumDistanceRadii: number; maximumDistanceM: number; } export type PreparedFocusPresentation = PreparedFocusDatasets & { selectDataset(datasetId: string): void }; +/** An object the world's host draws, as the registry holds it: what a focus is framed on and its card presents. */ +export type FocusObject = Pick; -/** The catalogue names the detailed package, independently of whether its bank is resident. */ -export function preparedFocusObjectId(record: PreparedCatalogObject): string | undefined { - return record.detailedObjectId; -} - -export function resolvePreparedFocus(record: PreparedCatalogObject, bankRadiusM: number | undefined, policy: PreparedFocusPolicy): PreparedNavigationFocus { - const radius = record.presentation?.focusRadiusM ?? bankRadiusM ?? - (!isPreparedCluster(record) && !isPreparedNebula(record) && record.halfLightRadius - ? record.halfLightRadius.valuePc * policy.metersPerParsec * 3 : policy.defaultFocusRadiusM); - return { id: record.id, positionM: record.positionM, framingRadiusM: radius, +/** The camera's focus on an object the host draws: at its world frame's origin, framed at the radius its package authors there. */ +export function resolvePreparedFocus(object: FocusObject, policy: PreparedFocusPolicy): PreparedNavigationFocus { + const radius = object.worldFrame.bodyRadiusM; + return { id: object.id, positionM: object.worldFrame.originM, framingRadiusM: radius, limits: { minimumDistanceM: radius * policy.minimumDistanceRadii, maximumDistanceM: policy.maximumDistanceM } }; } -export function preparedFocusCitations(record: PreparedCatalogObject, sources: readonly SpatialCatalogSource[]) { - const references = [record.skyPosition.sourceRef, record.distance.sourceRef, - (isPreparedCluster(record) || isPreparedNebula(record) ? record.classification.sourceRef : record.membership.sourceRef)] - .filter((reference): reference is string => Boolean(reference)); - return [...new Map(references.map(reference => { - const citation = resolveSpatialCitation(reference, sources); - if (!citation) throw new TypeError(`Unresolved prepared focus reference: ${reference}`); - return [citation.id, citation] as const; - })).values()]; -} - -/** An unavailable package still opens its catalogue facts, including from an older dataset link. */ +/** An unavailable package still opens its facts, including from an older dataset link. */ export function resolvePreparedFocusDataset(requested: string | null, datasets: Pick | null, unavailable = false): string | undefined { if (unavailable) return; diff --git a/site/scene/scene-router.mts b/site/scene/scene-router.mts index 815b73596d6..5a07fd557ec 100644 --- a/site/scene/scene-router.mts +++ b/site/scene/scene-router.mts @@ -14,6 +14,7 @@ import { isHostedObject } from '@cssearth/objects'; import type { NavigableObject } from '../objects.mts'; /** A placed object the world's host draws: its page is the host's scene framed on it. */ const hosted = (object: NavigableObject | null | undefined) => object != null && isHostedObject(object); +const hostedPage = (object: NavigableObject | null | undefined) => object != null && isHostedObject(object) ? object : null; import type { ObjectEntry } from '../objects.mts'; import type { ObjectDescriptor } from '@cssearth/objects'; import type { WorldCameraPose } from '@cssearth/renderer/navigation/world-camera.ts'; @@ -29,7 +30,6 @@ import { createWorldViewport } from '../world-viewport.mts'; import type { createSceneSelection, SceneSubject } from './scene-selection.mts'; import type { createSceneActivation } from './scene-activation.mts'; import { createCameraMotion } from '@cssearth/renderer/navigation'; -import { readInitialFocus } from '../focus-catalog.mts'; import { drawnPageFromUrl, isOverviewPage, WORLD_HOST_ID } from '../navigation/navigation-scope.mts'; import { createNavigationTiming } from '../navigation/navigation-timing.mts'; import { retainInitialScene } from '../initial-scene.mts'; @@ -131,7 +131,7 @@ export function createSceneRouter({ onFocusChange(session, focus) { const selection = context?.selection; if (!selection) return; - selection.focus(focus.record, focus.sources, focus.presentation); + selection.focus(focus.object, focus.presentation); if (focus.url === 'preserve') return; const url = new URL(navigationHref(windowTarget)); if (focus.url === 'reframe') url.searchParams.delete('v'); @@ -333,7 +333,7 @@ export function createSceneRouter({ const navigation = registry.createPreparedWorldNavigation({ objects: registry.SCENE_OBJECTS, motion: cameraMotion, windowTarget, documentTarget }); const selection = registry.createSceneSelection({ objectId, systems: objects, initial: registry.selectionTargetFromUrl(new URL(windowTarget.location?.href ?? 'https://example.test'), objectId, objects), - initialFocus: readInitialFocus(documentTarget), onChange: publishSelection }); + initialFocus: hostedPage(registry.knownObject(drawnPageFromUrl(windowTarget.location?.href ?? 'https://example.test', objectId) ?? '')), onChange: publishSelection }); const activation = registry.createSceneActivation({ windowTarget, navigation, view, isCurrent: scenes.isCurrent, getReducedMotion: () => reducedMotionActive }); if (windowTarget.location?.href && !destroyed) { // Only a settled scene belongs to the entry that history names. An unfinished navigation diff --git a/site/scene/scene-selection.mts b/site/scene/scene-selection.mts index 90417e92d30..cd82f1d6220 100644 --- a/site/scene/scene-selection.mts +++ b/site/scene/scene-selection.mts @@ -1,5 +1,4 @@ -import type { PreparedCatalogObject, SpatialCitation } from '@cssearth/catalog'; -import type { PreparedFocusPresentation } from '../prepared-focus.mts'; +import type { FocusObject, PreparedFocusPresentation } from '../prepared-focus.mts'; import type { WorldCameraPose } from '@cssearth/renderer/navigation/world-camera.ts'; import { overviewScopeAtCamera, type OverviewScope } from '../overview-context.mts'; import { overviewScopeFromUrl, preparedFocusFromUrl, satelliteSystemFromUrl, withOverviewScope, withPreparedFocus, withSatelliteSystemView } from '../navigation/navigation-scope.mts'; @@ -15,8 +14,8 @@ export type SceneContext = export type SelectionTarget = SceneContext | { readonly kind: 'focus'; readonly id: string }; export type SceneSubject = SceneContext | { readonly kind: 'focus'; readonly id: string; - readonly record: PreparedCatalogObject | null; - readonly sources: readonly SpatialCitation[]; + /** The registry object the page names; null until its entry is read. */ + readonly object: FocusObject | null; readonly presentation: PreparedFocusPresentation | null; /** The underlying camera context is restored when the native focus clears. */ readonly context: SceneContext; @@ -47,7 +46,7 @@ export function selectionKey(subject: SelectionTarget): string { /** One committed subject. Mounted scene ownership and temporary browsing/flight previews remain independent. */ export function createSceneSelection({ initial, objectId, initialFocus = null, systems = [], onChange }: { - initial: SelectionTarget; objectId: string; initialFocus?: PreparedCatalogObject | null; + initial: SelectionTarget; objectId: string; initialFocus?: FocusObject | null; /** The world's bodies, for a system overview's star: its overview is left by the distance from that star. */ systems?: SystemObjects; onChange(): void; @@ -55,7 +54,7 @@ export function createSceneSelection({ initial, objectId, initialFocus = null, s // The mounted scene: a URL that selects no focus is on its page. let scene = objectId; let subject: SceneSubject = initial.kind === 'focus' - ? { ...initial, record: initialFocus?.id === initial.id ? initialFocus : null, sources: [], presentation: null, + ? { ...initial, object: initialFocus?.id === initial.id ? initialFocus : null, presentation: null, context: { kind: 'object', objectId } } : initial; function publish(next: SceneSubject, notify = true) { if (subject === next) return false; @@ -75,11 +74,11 @@ export function createSceneSelection({ initial, objectId, initialFocus = null, s : context.kind === 'satellite-system' ? context.hostId : context.overview.systemId; const retained = subject.kind === 'focus' && subject.id === target.id ? subject : null; if (retained && host === mountedObjectId) return false; - return publish({ ...target, record: retained?.record ?? null, sources: retained?.sources ?? [], presentation: retained?.presentation ?? null, + return publish({ ...target, object: retained?.object ?? null, presentation: retained?.presentation ?? null, context: host === mountedObjectId ? context : { kind: 'object', objectId: mountedObjectId } }, notify); }, - focus(record: PreparedCatalogObject | null, sources: readonly SpatialCitation[], presentation: PreparedFocusPresentation | null) { - return publish(record ? { kind: 'focus', id: record.id, record, sources, presentation, context: selectionContext(subject) } + focus(object: FocusObject | null, presentation: PreparedFocusPresentation | null) { + return publish(object ? { kind: 'focus', id: object.id, object, presentation, context: selectionContext(subject) } : selectionContext(subject)); }, followCamera(world: WorldCameraPose) { diff --git a/site/search/prepared-catalogue-index.mts b/site/search/prepared-catalogue-index.mts index 2b17fb1e710..fee059d44d1 100644 --- a/site/search/prepared-catalogue-index.mts +++ b/site/search/prepared-catalogue-index.mts @@ -1,5 +1,4 @@ import { distanceDescription, isSceneObject } from '@cssearth/objects'; -import { FOCUS_SOURCE_DOCUMENTS } from '../focus-catalog-data.mts'; import { objectClassificationLabel, objectTypeLabel, SEARCH_OBJECTS } from './search-objects.mts'; import { sidebarThumbnail } from '../sidebar-thumbnails.mts'; import { sourceDocumentation, systemSourceDocumentation } from '../source-documentation.mts'; @@ -35,8 +34,7 @@ export function preparedCatalogueIndex(): CatalogueIndex { marker: Object.freeze({ kind: 'scene' as const, id: object.id, color: object.color }), }); } - const source = FOCUS_SOURCE_DOCUMENTS.get(object.id); - if (!source) throw new Error(`Missing focus source document: ${object.id}`); + const source = sourceDocumentation(object.id, object.name); const value = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 }).format(object.distance.value); const detail = `${value} pc${object.distance.quantity === 'comoving' ? ' (comoving)' : ''}`; return Object.freeze({ diff --git a/site/shell/object-shell-client.mts b/site/shell/object-shell-client.mts index a4fb60fbea6..5511a0ec48a 100644 --- a/site/shell/object-shell-client.mts +++ b/site/shell/object-shell-client.mts @@ -126,7 +126,7 @@ export function mountObjectShell({ if (presentedSubject === subject) { updateBodyCard(); return; } // What the world draws around the scene (a catalogue focus, an overview) is its own page, `//`. const drawnPage = (selected: typeof subject | null) => { - if (selected?.kind === 'focus') return selected.record ? { id: selected.record.id, seo: focusSeo(selected.record) } : null; + if (selected?.kind === 'focus') return selected.object ? { id: selected.object.id, seo: focusSeo(selected.object) } : null; const overview = selected?.kind === 'overview' ? knownObject(overviewPage(objectId, selected.overview.scope) ?? '') : undefined; return overview?.zoom !== undefined ? { id: overview.id, seo: objectSeo(overview) } : null; }; @@ -136,15 +136,15 @@ export function mountObjectShell({ // Leaving one returns to the scene's page, whose head and forms a scene change would otherwise write // (object-browser.mts bindObject). if (page) presentPage(`/${page.id}/`, page.seo); - // A focus whose record is still loading is already this page's subject: its head waits for the record. + // A focus whose entry is still loading is already this page's subject: its head waits for the record. else if (leftPage && !focus) { const scene = knownObject(subject.kind === 'object' ? subject.objectId : subject.kind === 'satellite-system' ? subject.hostId : objectId); if (scene && isSceneObject(scene)) presentPage(scene.route, objectSeo(scene)); } else pendingPage = null; - focusCard.set(focus?.record ?? null, focus?.sources ?? [], focus?.presentation ?? null); + focusCard.set(focus?.object ?? null, focus?.presentation ?? null); if (focus) loadFocusBanks(); objectBrowser.refreshSelection(); - viewReadout.setPreparedFocus(focus?.record ?? null); + viewReadout.setPreparedFocus(focus?.object ? { name: focus.object.name, positionM: focus.object.worldFrame.originM } : null); viewReadout.setOverviewScope(subject.kind === 'overview' ? subject.overview.scope : 'system'); updateBodyCard(); } @@ -303,7 +303,7 @@ export function mountObjectShell({ settingsController.bindObject(); bindCardMaps(); const subject = readSelection(); - viewReadout.setPreparedFocus(subject.kind === 'focus' ? subject.record : null); + viewReadout.setPreparedFocus(subject.kind === 'focus' && subject.object ? { name: subject.object.name, positionM: subject.object.worldFrame.originM } : null); informationCard.activatePanels(); } } diff --git a/site/source-documentation.mts b/site/source-documentation.mts index c4731a85b71..e5ddd97357e 100644 --- a/site/source-documentation.mts +++ b/site/source-documentation.mts @@ -1,7 +1,6 @@ import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { isPreparedCluster, type PreparedCatalogObject } from '@cssearth/catalog'; import credits from './prepared-source-credits.json' with { type: 'json' }; import { parseSourceCredits } from '@cssearth/objects/provenance'; import { projectRoot } from '@cssearth/core/node'; @@ -47,11 +46,6 @@ export function sourceDocumentation(objectId: string, name: string) { label: creditLabel(sourceProviders(objectId)) }; } -export function focusSourceDocumentation(object: PreparedCatalogObject, catalogId: string) { - const owner = object.detailedObjectId ?? (isPreparedCluster(object) ? 'galaxy-clusters' : catalogId === 'galaxies' ? 'local-group' : object.id); - return sourceDocumentation(owner, object.name); -} - // A star hosts its system overview, but its own maps do not own the other bodies' // credits. Prepare the overview's union from the system's prepared members. export function systemSourceDocumentation(system: { readonly id: string; readonly name: string; readonly memberIds: readonly string[] }) { diff --git a/site/test/focus-catalog.test.mts b/site/test/focus-catalog.test.mts deleted file mode 100644 index bf76ddbc401..00000000000 --- a/site/test/focus-catalog.test.mts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import { sourceTest } from '@cssearth/objects/node/source-test'; -const test = sourceTest(); -import { readFile } from 'node:fs/promises'; -import { parseHTML } from 'linkedom'; -import { parsePreparedGalaxyCatalog } from '@cssearth/catalog'; -import { initialFocusCatalog, readInitialFocus } from '../focus-catalog.mts'; - -const catalogue = parsePreparedGalaxyCatalog(JSON.parse(await readFile( - new URL('../../src/objects/local-group/prepared/catalogue.json', import.meta.url), 'utf8'))); -const smc = catalogue.objects.find(object => object.id === 'smc')!; - -test('an initial galaxy focus retains its transitive positioned and unpositioned physical hosts', () => { - const fragment = initialFocusCatalog(catalogue, smc); - assert.equal(fragment.schema, 'cssearth-galaxy-catalog@1'); - assert.deepEqual(fragment.objects.map(object => object.id), ['lmc', 'smc']); - assert.deepEqual(fragment.unpositionedHosts?.map(object => object.id), ['milky-way']); - const document = parseHTML(``).document; - assert.equal(readInitialFocus(document)?.id, 'smc', 'the explicit identity selects SMC rather than its first positioned host'); -}); - -test('initial focus host closure fails on a missing host and a cycle', () => { - const missing = { ...catalogue, objects: catalogue.objects.map(object => object.id === 'smc' ? { ...object, hostId: 'missing-host' } : object) }; - assert.throws(() => initialFocusCatalog(missing, missing.objects.find(object => object.id === 'smc')!), /Unknown physical host: missing-host/); - const cyclic = { ...catalogue, objects: catalogue.objects.map(object => object.id === 'lmc' ? { ...object, hostId: 'smc' } : object) }; - assert.throws(() => initialFocusCatalog(cyclic, cyclic.objects.find(object => object.id === 'smc')!), /Cyclic physical host: smc/); -}); - -test('initial focus requires an explicit selected identity present in the parsed fragment', () => { - const fragment = initialFocusCatalog(catalogue, smc); - for (const id of ['', 'not-present']) { - const document = parseHTML(``).document; - assert.throws(() => readInitialFocus(document), /Initial focus identity/); - } -}); diff --git a/site/test/navigation-ontology.test.mts b/site/test/navigation-ontology.test.mts index a5353d045db..4004a077cbd 100644 --- a/site/test/navigation-ontology.test.mts +++ b/site/test/navigation-ontology.test.mts @@ -7,7 +7,7 @@ import { objectAdapter } from '../object-adapter.mts'; import { SEARCH_OBJECTS } from '../search/search-objects.mts'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { readObjectDescriptors, readOverviews } from '@cssearth/objects/node'; -import { distanceDescription, isExtendedClassification, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { distanceDescription, isExtendedClassification, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, objectFacts, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import { parsePreparedGalaxyCatalog, resolveSpatialCitation } from '@cssearth/catalog'; import { resolve } from 'node:path'; @@ -31,6 +31,8 @@ test('every scene and every package the host draws has exactly one searchable de const object = requireObject(id!); assert.ok(isHostedObject(object) && object.searchNames.some(name => name.includes(normalizeDestinationQuery(query!))), query); } + // Each states its card's facts itself, every one with its source; no catalogue row is read for them. + for (const id of [...hosted, 'local-group']) assert.ok(objectFacts(descriptors.get(id)).some(fact => fact.id === 'distance'), id); // Each is a body of the world, placed by its own world frame. for (const id of hosted) { const object = requireObject(id); diff --git a/site/test/prepared-context-navigation.test.mts b/site/test/prepared-context-navigation.test.mts index 1477d60400e..813dc916d85 100644 --- a/site/test/prepared-context-navigation.test.mts +++ b/site/test/prepared-context-navigation.test.mts @@ -7,16 +7,15 @@ import { createNavigationLifecycle } from '../navigation/navigation-lifecycle.mt 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 { FocusObject, PreparedFocusPresentation } from '../prepared-focus.mts'; 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>; +type Content = { object: FocusObject | null; presentation: PreparedFocusPresentation | null }; +/** `detailedObjectId` names the fixture bank the object's package holds; `presentation.focusRadiusM` the framing radius its world frame authors. */ +type FixtureOptions = { object?: { detailedObjectId?: string; presentation?: { focusRadiusM: number }; classification?: FocusObject['classification'] }; unavailableObjectIds?: readonly string[]; bankReady?: Promise; loadedFramingRadiusUnits?: number; imageLayerFrames?: Readonly>; volumeDatasetFrames?: Readonly>; volumeBank?: PreparedVolumeDatasetState | null; deferredVolumeBank?: PreparedVolumeDatasetState | null; @@ -80,20 +79,20 @@ function fixture({ object = {}, imageLayerFrames = {}, volumeDatasetFrames = {}, 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) { + selectGalaxy: (id: string | null, focus?: PreparedNavigationFocus | null) => { selections.push(id); presentationFocuses.push(focus); } }; + const bankKey = (id: string) => ['catalogue-a', 'catalogue-b'].includes(id) ? object.detailedObjectId ?? id : id; + // An object's world frame authors its framing radius: here the fixture's own, else what its bank frames. + const objectOf = (id: string): FocusObject | null => { + if (!['catalogue-a', 'catalogue-b'].includes(id)) return null; + const key = bankKey(id), volume = currentVolumeDatasetFrames[key]; + const bodyRadiusM = object.presentation?.focusRadiusM ?? (imageLayerFrames[key] ? 500 * imageLayerFrames[key].metersPerUnit + : volume ? (loadedFramingRadiusUnits ?? volume.framingRadiusUnits) * volume.frame.metersPerUnit : 1e18); + return { id, name: id, description: `The ${id}.`, aliases: [], classification: object.classification ?? 'galaxy', + worldFrame: { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [1e20, 0, 0], presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], metersPerUnit: bodyRadiusM, bodyRadiusM } }; + }; + const focusLayer: ContextLayer = { selectGalaxy: layer.selectGalaxy, + focusBank(objectId) { + const id = bankKey(objectId); if (imageLayerFrames[id]) return createImageFocusBank(id, imageLayerFrames[id], () => Promise.resolve()); if (!currentVolumeDatasetFrames[id]) return null; return { objectId: id, @@ -102,21 +101,18 @@ function fixture({ object = {}, imageLayerFrames = {}, volumeDatasetFrames = {}, 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 } }); + objects: { known: objectOf, load: async id => objectOf(id) }, 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); + onFocusChange: ({ object, presentation, url: policy }) => { + available = true; content.push({ object, presentation }); + selection.focus(object, presentation); if (policy === 'preserve') return; const url = new URL(windowTarget.location.href); if (policy === 'reframe') url.searchParams.delete('v'); @@ -146,12 +142,12 @@ function fixture({ object = {}, imageLayerFrames = {}, volumeDatasetFrames = {}, 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'] }); +test('a saved dataset link to an unavailable package still opens its object', () => { + const f = fixture({ unavailableObjectIds: ['catalogue-a'] }); 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).object?.id, 'catalogue-a'); assert.equal(last(f.content).presentation, null); assert.deepEqual(f.datasetWrites, []); f.controller.destroy(); @@ -209,12 +205,10 @@ test('a focus link whose saved camera puts the named target off-screen reframes 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'); + assert.equal(required(last(f.content).object).id, 'catalogue-a'); 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'); + assert.equal(required(last(f.content).object).id, 'catalogue-b'); const incoming = 'https://example.test/catalogue-a/?v=restored'; f.windowTarget.location = new URL(incoming); f.controller.suspend(); @@ -225,7 +219,7 @@ test('suspension isolates camera restore publications from incoming focus histor 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(required(last(f.content).object).id, 'catalogue-a'); assert.equal(f.windowTarget.location.href, incoming); assert.deepEqual(f.errors, []); f.controller.destroy(); @@ -242,10 +236,10 @@ test('an invalid incoming catalogue ID remains available for diagnosis after res 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.match(f.errors[0].message, /Unknown object the world's host draws/); assert.equal(f.windowTarget.location.href, incoming); assert.equal(f.selections.at(-1), null); - assert.equal(last(f.content).record, null); + assert.equal(last(f.content).object, null); f.controller.destroy(); }); @@ -264,16 +258,12 @@ test('an authored framing radius takes precedence over an oversized transparent 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 } } }); +test('a cluster focus is framed at its own authored radius', () => { + const f = fixture({ object: { classification: 'galaxy-cluster', 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'); + assert.equal(required(last(f.content).object).classification, 'galaxy-cluster'); f.controller.destroy(); }); @@ -342,7 +332,7 @@ test('a saved dataset waits for its lazy bank instead of rejecting the focus as 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(last(f.content).object?.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(); diff --git a/site/test/prepared-focus-card.test.mts b/site/test/prepared-focus-card.test.mts index 7213d9943b7..7905f286057 100644 --- a/site/test/prepared-focus-card.test.mts +++ b/site/test/prepared-focus-card.test.mts @@ -2,8 +2,7 @@ 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'; +import type { FocusObject, PreparedFocusPresentation } from '../prepared-focus.mts'; class Element extends EventTarget { dataset: Record = {}; selectors = new Map(); attributes = new Map(); hidden = false; checked = false; disabled = false; textContent = ''; @@ -36,14 +35,14 @@ function fixture(unavailableObjects = '', { banksLater = false } = {}) { 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']) { + for (const name of ['name', 'aliases', 'introduction', 'status']) { 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' } }; + // The registry object the card presents: its id is its package's, so its banks are found by it. + const record: FocusObject = { id: 'prepared-galaxy', name: 'Prepared galaxy', description: 'A galaxy 50,000 parsecs away.', aliases: [], classification: 'galaxy', + worldFrame: { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [0, 0, 1e21], presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], metersPerUnit: 1e20, bodyRadiusM: 1e20 } }; 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. @@ -51,15 +50,15 @@ function fixture(unavailableObjects = '', { banksLater = false } = {}) { 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', () => { +test('an unavailable volume keeps its 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.root.querySelector('[data-focus-introduction]')?.textContent, 'A galaxy 50,000 parsecs away.'); + f.card.set({ ...f.record, id: 'other-galaxy' }); assert.equal(f.unavailable.hidden, true); f.card.set(f.record); f.card.set(null); assert.equal(f.unavailable.hidden, true); @@ -69,7 +68,7 @@ test('an unavailable volume keeps catalogue facts and a retained explanation, wi 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); + 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]); @@ -79,12 +78,12 @@ test('prepared focus datasets retain controls and reflect only the applied runti 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' }); + 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.set(f.record, { ...f.presentation, selectedDataset: 'third' }); f.card.destroy(); f.buttons[0].dispatchEvent(new Event('click')); assert.deepEqual(requested, ['second']); @@ -93,13 +92,13 @@ test('prepared focus datasets retain controls and reflect only the applied runti 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' }); + f.card.set(f.record, f.presentation); + f.card.set({ ...f.record, id: '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); + 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'); @@ -109,40 +108,22 @@ test('departed or unsupported galaxy focus hides its retained dataset bank and d 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('a nebula or globular-cluster focus keeps classification out of the title and preserves the shared dataset controls', () => { + for (const classification of ['nebula', 'globular-cluster'] as const) { + const f = fixture(); + f.card.set({ ...f.record, classification, aliases: ['Example 1'], description: 'A source-backed introduction.' }, f.presentation); + assert.equal(f.root.querySelector('[data-focus-introduction]')?.textContent, 'A source-backed introduction.'); + 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.bank.hidden, false); + assert.equal(f.datasetTab.hidden, false); + f.card.set(f.record, f.presentation); + assert.equal(f.root.querySelector('[data-focus-status]')?.hidden, false, 'A later galaxy restores its type tag'); + assert.equal(f.root.querySelector('[data-focus-status]')?.textContent, 'Galaxy'); + f.card.destroy(); + } }); test('focus uses shared dataset tabs only when a prepared presentation is available', () => { @@ -150,12 +131,12 @@ test('focus uses shared dataset tabs only when a prepared presentation is availa f.card.set(f.record); assert.equal(f.datasetTab.hidden, true); assert.deepEqual(f.tabs, ['factsheet']); - f.card.set(f.record, [], f.presentation); + 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' }); + 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 }); + f.card.set({ ...f.record, id: 'other-galaxy' }); assert.equal(f.datasetTab.hidden, true); assert.deepEqual(f.tabs, ['factsheet', 'dataset', 'factsheet']); f.card.destroy(); @@ -164,7 +145,7 @@ test('focus uses shared dataset tabs only when a prepared presentation is availa 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' }); + 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(); @@ -188,8 +169,6 @@ test('Wikipedia follows the selected focus without replacing the retained link', 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/view-readout.mts b/site/view-readout.mts index d17550afad9..df509a51ded 100644 --- a/site/view-readout.mts +++ b/site/view-readout.mts @@ -1,5 +1,4 @@ import { formatViewDate, formatViewDistance, formatViewCoordinate, viewScale } from './minimap/view-format.mts'; -import type { PreparedCatalogObject } from '@cssearth/catalog'; import type { WorldCameraPose } from '@cssearth/renderer/navigation/world-camera.ts'; import type { PositionM } from '@cssearth/engine'; import type { BrowserWindow, ShellCamera, PlaybackState } from './browser/browser-types.mts'; @@ -13,7 +12,7 @@ import { measureView } from './minimap/view-measure.mts'; import { surfaceMapContext, surfaceMapViewport } from './minimap/surface-map-context.mts'; import { viewDistance } from './overview-context.mts'; import { dotN as dot } from '@cssearth/core'; -type PreparedFocus = Pick; +type PreparedFocus = { readonly name: string; readonly positionM: readonly [number, number, number] }; interface ViewReadout { bindObject(): void; setPreparedFocus(record: PreparedFocus | null): void; setCamera(camera: ShellCamera | null): void; setOverviewScope(scope: OverviewScope): void; setPlaybackState(state: PlaybackState): void; setNavigationInFlight(active: boolean): void; destroy(): void; } export function measurePreparedFocusView(world: WorldCameraPose, focus: PreparedFocus, focalPixels: number) { diff --git a/src/objects/fornax-cluster/object.json b/src/objects/fornax-cluster/object.json index ed5417723b8..b17d6745831 100644 --- a/src/objects/fornax-cluster/object.json +++ b/src/objects/fornax-cluster/object.json @@ -42,7 +42,56 @@ ], "metersPerUnit": 1.912022062157274e+22, "bodyRadiusM": 1.912022062157274e+22 - } + }, + "facts": [ + { + "id": "distance", + "label": "Group distance", + "value": "19.66 Mpc", + "note": "Cosmicflows-4 group distance: the weighted average of its members' distance moduli on the calibrated scale (table 3, DMzp).", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/ApJ/944/94" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 54.62°, Dec -35.45°", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "X-ray selected galaxy cluster", + "note": "MCXC-II R500 is an X-ray-derived overdensity aperture (500 times the critical density), not the cluster boundary or member distribution.", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + }, + { + "id": "redshift", + "label": "Redshift", + "value": "0.0051", + "source": { + "label": "Redshift reference 2004A&A...425..367B, transcribed from MCXC-II.", + "url": "https://ui.adsabs.harvard.edu/abs/2004A&A...425..367B" + } + }, + { + "id": "aperture", + "label": "R500 radius", + "value": "0.411 Mpc", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + } + ] }, "prepared": { "format": "cssearth-catalogue-points-bin@1", diff --git a/src/objects/helix/object.json b/src/objects/helix/object.json index 53100e4bae2..fc96de31409 100644 --- a/src/objects/helix/object.json +++ b/src/objects/helix/object.json @@ -68,7 +68,38 @@ ], "metersPerUnit": 32313140071200, "bodyRadiusM": 48469710106800000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "216 pc (−12 / +14 pc)", + "note": "Adopted published trigonometric parallax; not a fitted image scale.", + "source": { + "label": "Benedict et al. (2009), HST parallaxes of planetary nebula nuclei", + "url": "https://arxiv.org/abs/0909.4281" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 337.4°, Dec -20.84°", + "source": { + "label": "ESO ESO WFI · optical detail image and publisher astrometry; registration retained in the observation recipe", + "url": "https://cdn.eso.org/images/original/eso0907a.tif" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "Planetary nebula", + "note": "Source-colored relative emission fitted to aligned ESO images with a molecular-velocity scaffold where measured. Unsupported depth and the front/back assignment remain conditional. Compact lights have observed image positions, with illustrative field depths.", + "source": { + "label": "ESO ESO WFI · optical detail image and publisher astrometry; registration retained in the observation recipe", + "url": "https://cdn.eso.org/images/original/eso0907a.tif" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/lmc/object.json b/src/objects/lmc/object.json index f2fe5918b7f..6c23cc33f68 100644 --- a/src/objects/lmc/object.json +++ b/src/objects/lmc/object.json @@ -70,7 +70,46 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 154283879074568370000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "49.59 kpc (−545.1 / +551.1 pc)", + "source": { + "label": "Pietrzynski2019Natur.567..200P: A distance to the Large Magellanic Cloud that is precise to one per cent", + "url": "https://ui.adsabs.harvard.edu/abs/2019Natur.567..200P" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 78.76°, Dec -69.19°", + "source": { + "label": "vanderMarel2014ApJ...781..121V: Third-epoch Magellanic Cloud Proper Motions. II. The Large Magellanic Cloud Rotation Field in Three Dimensions", + "url": "https://ui.adsabs.harvard.edu/abs/2014ApJ...781..121V" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Local group, Milky way", + "note": "Author catalogue host association: lmc -> mw; object confirmation is recorded separately.", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + }, + { + "id": "half-light-radius", + "label": "Half-light radius", + "value": "2.783 kpc", + "source": { + "label": "Choi2018ApJ...869..125C: SMASHing the LMC: Mapping a Ring-like Stellar Overdensity in the LMC Disk", + "url": "https://ui.adsabs.harvard.edu/abs/2018ApJ...869..125C" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/local-group/object.json b/src/objects/local-group/object.json index f9215ff1cbd..3071123bdab 100644 --- a/src/objects/local-group/object.json +++ b/src/objects/local-group/object.json @@ -66,7 +66,39 @@ ], "metersPerUnit": 3.2708182363808494e+22, "bodyRadiusM": 3.2708182363808494e+22 - } + }, + "facts": [ + { + "id": "centre", + "label": "Centre", + "value": "Midway between the Milky Way and M31", + "note": "The paper places the Local Group's barycentre at the mid-point of the vector connecting the two (Table 2, notes to columns 8 to 10); the marker uses this repository's positions of the Galactic centre and M31.", + "source": { + "label": "McConnachie (2012), The Observed Properties of Dwarf Galaxies in and around the Local Group, AJ 144, 4", + "url": "https://arxiv.org/abs/1204.1562" + } + }, + { + "id": "distance", + "label": "Distance to the centre", + "value": "386.1 kpc", + "note": "The Sun's distance from that mid-point, from the same two positions.", + "source": { + "label": "McConnachie (2012), The Observed Properties of Dwarf Galaxies in and around the Local Group, AJ 144, 4", + "url": "https://arxiv.org/abs/1204.1562" + } + }, + { + "id": "radius", + "label": "Zero-velocity radius", + "value": "1,060 ± 70 kpc", + "note": "The mean distance of the galaxies at the group's zero-velocity surface (section 3).", + "source": { + "label": "McConnachie (2012), The Observed Properties of Dwarf Galaxies in and around the Local Group, AJ 144, 4", + "url": "https://arxiv.org/abs/1204.1562" + } + } + ] }, "prepared": { "format": "cssearth-galaxy-catalog@1", diff --git a/src/objects/m1/object.json b/src/objects/m1/object.json index 19582bb33f4..358ff4f96fb 100644 --- a/src/objects/m1/object.json +++ b/src/objects/m1/object.json @@ -70,7 +70,38 @@ ], "metersPerUnit": 299195741400000, "bodyRadiusM": 40627486340224216 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "2 kpc", + "note": "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.", + "source": { + "label": "Martin, Milisavljevic & Drissen (2021), 3D mapping of the Crab Nebula with SITELLE. I", + "url": "https://arxiv.org/pdf/2101.02709v1" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 83.63°, Dec 22.02°", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m1/observations.json" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "Supernova remnant and pulsar wind nebula", + "note": "Released SITELLE ejecta samples use a conditional expansion-to-depth model; a separate published torus interpretation and authored jets describe the pulsar wind. Spectral display colors are not calibrated gas or dust density.", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m1/observations.json" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/m2-9/object.json b/src/objects/m2-9/object.json index 5f4c4aaf9f6..2cf9f01aad8 100644 --- a/src/objects/m2-9/object.json +++ b/src/objects/m2-9/object.json @@ -69,7 +69,38 @@ ], "metersPerUnit": 564324590702700, "bodyRadiusM": 2821622953513500 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "650 pc", + "note": "Adopted model distance, not a parallax. Published expansion alternative:1300±200pc.", + "source": { + "label": "Sánchez Contreras et al. A&A 692, A151 (2024)", + "url": "https://arxiv.org/html/2411.03825v1" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 256.4°, Dec -10.14°", + "source": { + "label": "NASA/ESA Hubble 1997 image opo9738a; AVM sky astrometry", + "url": "https://esahubble.org/media/archives/images/original/opo9738a.tif" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "Bipolar nebula", + "note": "Axially symmetric, image-conditioned Hubble emission model; single-image depth and inclination remain unmeasured. Adopted650pc model distance is uncertain; published expansion distance1300±200pc would double this physical size. The accepted baseline contains no stellar catalogue.", + "source": { + "label": "NASA/ESA Hubble 1997 image opo9738a; AVM sky astrometry", + "url": "https://esahubble.org/media/archives/images/original/opo9738a.tif" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/m31/object.json b/src/objects/m31/object.json index 85e779cd54e..86ebf6e120d 100644 --- a/src/objects/m31/object.json +++ b/src/objects/m31/object.json @@ -72,7 +72,37 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 771419395372841800000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "776.2 kpc (−21,150 / +21,750 pc)", + "source": { + "label": "Savino2022ApJ...938..101S: The Hubble Space Telescope Survey of M31 Satellite Galaxies. I. RR Lyrae-based Distances and Refined 3D Geometric Structure", + "url": "https://ui.adsabs.harvard.edu/abs/2022ApJ...938..101S" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 10.68°, Dec 41.27°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Local group, Andromeda", + "note": "Author catalogue host association: m_031; object confirmation is recorded separately.", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m33/object.json b/src/objects/m33/object.json index 7b6838f0881..1203448ca95 100644 --- a/src/objects/m33/object.json +++ b/src/objects/m33/object.json @@ -74,7 +74,37 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 277710982334223060000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "859 kpc (−23,410 / +24,070 pc)", + "source": { + "label": "Savino2022ApJ...938..101S: The Hubble Space Telescope Survey of M31 Satellite Galaxies. I. RR Lyrae-based Distances and Refined 3D Geometric Structure", + "url": "https://ui.adsabs.harvard.edu/abs/2022ApJ...938..101S" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 23.46°, Dec 30.66°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Local group, Andromeda", + "note": "Author catalogue host association: m_033 -> m_031; object confirmation is recorded separately.", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m42/object.json b/src/objects/m42/object.json index 21ae59f992c..1207971f92e 100644 --- a/src/objects/m42/object.json +++ b/src/objects/m42/object.json @@ -69,7 +69,38 @@ ], "metersPerUnit": 61933518469800, "bodyRadiusM": 148640444327520000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "414 pc (−7 / +7 pc)", + "note": "Adopted published trigonometric parallax; not a fitted image scale.", + "source": { + "label": "Menten et al. (2007), The distance to the Orion Nebula", + "url": "https://arxiv.org/abs/0709.0485" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 83.79°, Dec -5.4°", + "source": { + "label": "ESO ESO · optical image and publisher astrometry; registration retained in the observation recipe", + "url": "https://cdn.eso.org/images/publicationtiff/eso1723a.tif" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "H II region", + "note": "Source-colored relative emission fitted to aligned ESO images. Its coherent depth surface is an evidence-guided hypothesis, not measured gas density. Compact-light positions and display photometry are image-derived; their depths are conditional, not measured stellar distances.", + "source": { + "label": "ESO ESO · optical image and publisher astrometry; registration retained in the observation recipe", + "url": "https://cdn.eso.org/images/publicationtiff/eso1723a.tif" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/m45/object.json b/src/objects/m45/object.json index fc4f49e9875..ab888b9086c 100644 --- a/src/objects/m45/object.json +++ b/src/objects/m45/object.json @@ -76,7 +76,38 @@ ], "metersPerUnit": 20375229989339.996, "bodyRadiusM": 109203529398159400 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "136.2 pc (−1.2 / +1.2 pc)", + "note": "Adopted VLBI stellar-cluster distance from Melis et al. (2014), main text and Table 1; not a measured distance to each dust filament.", + "source": { + "label": "Melis et al. (2014), A VLBI Resolution of the Pleiades Distance Controversy", + "url": "https://arxiv.org/pdf/1408.6544" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 56.75°, Dec 24.12°", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m45/processing-observations.json" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "Reflection nebula around an open cluster", + "note": "Observed optical reflection nebulosity and infrared dust emission painted on an authored finite depth surface. The stellar distance locates the scene; dust depth, scattering and individual stellar distances are not recovered.", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m45/processing-observations.json" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/m8/object.json b/src/objects/m8/object.json index 6d0803f219c..42000c17a49 100644 --- a/src/objects/m8/object.json +++ b/src/objects/m8/object.json @@ -70,7 +70,38 @@ ], "metersPerUnit": 198366776548200, "bodyRadiusM": 677379746660464100 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "1.326 kpc (−69 / +77 pc)", + "note": "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.", + "source": { + "label": "Wright et al. (2019), The Gaia-ESO Survey: Asymmetric expansion of the Lagoon Nebula cluster NGC 6530 from GES and Gaia DR2", + "url": "https://arxiv.org/pdf/1903.12176v1" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 270.8°, Dec -24.31°", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m8/processing-observations.json" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "H II region and star-forming nebula", + "note": "Observed optical and infrared emission on an authored coherent front with local literature-guided structure. Foreground extinction, distinct molecular clouds and true front/back distances are not reconstructed.", + "source": { + "label": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid.", + "url": "https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m8/processing-observations.json" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/m81/object.json b/src/objects/m81/object.json index a960b9a5582..2a7f7a02dd9 100644 --- a/src/objects/m81/object.json +++ b/src/objects/m81/object.json @@ -75,7 +75,28 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 509136800946075600000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "3.614 Mpc (−65,960 / +33,440 pc)", + "note": "trgb", + "source": { + "label": "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", + "url": "https://ui.adsabs.harvard.edu/abs/2024ApJ...966..175N" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 148.9°, Dec 69.07°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m83/object.json b/src/objects/m83/object.json index 7ee338ba145..c17221cbf3d 100644 --- a/src/objects/m83/object.json +++ b/src/objects/m83/object.json @@ -74,7 +74,27 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 286968015078697140000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "4.898 Mpc (−67,200 / +68,140 pc)", + "source": { + "label": "Tully2009AJ....138..323T: The Extragalactic Distance Database", + "url": "https://ui.adsabs.harvard.edu/abs/2009AJ....138..323T" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 204.3°, Dec -29.87°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/m87/object.json b/src/objects/m87/object.json index 9dbed0de970..ae621757c9c 100644 --- a/src/objects/m87/object.json +++ b/src/objects/m87/object.json @@ -70,7 +70,38 @@ ], "metersPerUnit": 1.238634295408084e+21, "bodyRadiusM": 6.140498387167821e+21 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "16.8 Mpc (−800,000 / +800,000 pc)", + "note": "adopted", + "source": { + "label": "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.", + "url": "https://arxiv.org/abs/1906.11238" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 187.7°, Dec 12.39°", + "source": { + "label": "Charlot et al. (2020), A&A 644, A159: ICRF3, the position SIMBAD adopts for M 87.", + "url": "https://doi.org/10.1051/0004-6361/202038368" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Galaxy cluster, Virgo", + "note": "The Virgo Cluster Catalog classes VCC 1316 (NGC 4486) a member; it is the central giant elliptical of the cluster.", + "source": { + "label": "Binggeli, Sandage & Tammann (1985), AJ 90, 1681: the Virgo Cluster Catalog, VCC 1316 = NGC 4486, membership M (member).", + "url": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/AJ/90/1681" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/ngc-253/object.json b/src/objects/ngc-253/object.json index 1056d8845c7..0b8bbe6d850 100644 --- a/src/objects/ngc-253/object.json +++ b/src/objects/ngc-253/object.json @@ -73,7 +73,28 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 614049838716782100000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "3.467 Mpc (−110,000 / +113,600 pc)", + "note": "trgb", + "source": { + "label": "RadburnSmith2011ApJS..195...18R: The GHOSTS Survey. I. Hubble Space Telescope Advanced Camera for Surveys Data", + "url": "https://ui.adsabs.harvard.edu/abs/2011ApJS..195...18R" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 11.89°, Dec -25.29°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/ngc-300/object.json b/src/objects/ngc-300/object.json index 64a8a6162f0..7b329bb40f0 100644 --- a/src/objects/ngc-300/object.json +++ b/src/objects/ngc-300/object.json @@ -68,7 +68,47 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 290053692660188500000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "2.089 Mpc (−56,940 / +58,530 pc)", + "note": "trgb", + "source": { + "label": "Tully2009AJ....138..323T: The Extragalactic Distance Database", + "url": "https://ui.adsabs.harvard.edu/abs/2009AJ....138..323T" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 13.72°, Dec -37.68°", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Local volume, Field", + "note": "Published neighboring galaxy (N), not a Local Group membership assertion.", + "source": { + "label": "McConnachie (2012), AJ 144, 4, DOI 10.1088/0004-6256/144/1/4; author October 2019 Table 1 update, read from its text transcription (first seven pages, pypdf 5.9.0; numeric membership classifications unchanged).", + "url": "https://cadc-west-01.canfar.net/vault/files/PANDAS/NearbyGals/table1_OCT2019.pdf" + } + }, + { + "id": "half-light-radius", + "label": "Half-light radius", + "value": "3.039 kpc", + "source": { + "label": "McConnachie2012AJ....144....4M: The Observed Properties of Dwarf Galaxies in and around the Local Group", + "url": "https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract" + } + } + ] }, "prepared": { "format": "cssearth-image-layer-bank@1", diff --git a/src/objects/omega-centauri/object.json b/src/objects/omega-centauri/object.json index d7acf3e2f80..e54c9709cd2 100644 --- a/src/objects/omega-centauri/object.json +++ b/src/objects/omega-centauri/object.json @@ -70,7 +70,38 @@ ], "metersPerUnit": 811718046418199.9, "bodyRadiusM": 1339334776590029800 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "5.426 kpc (−47 / +47 pc)", + "note": "Combined Gaia EDR3, HST and literature distance, Table 2, NGC 5139. One cluster scale; individual stellar distances are not recovered.", + "source": { + "label": "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.", + "url": "https://arxiv.org/pdf/2105.09526" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 201.7°, Dec -47.48°", + "source": { + "label": "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.", + "url": "https://cdn.eso.org/images/original/eso1119b.tif" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "Globular stellar cluster", + "note": "Observed integrated cluster starlight; the selected oblate Gaussian profile supplies conditional depth for finite display-emission components.", + "source": { + "label": "D’Souza & Rix (2013), Mass estimates from stellar proper motions: the mass of omega Centauri, Table 1 and section 4.3. Eight projected Gaussian components define a conditional oblate light prior, not resolved stellar depths.", + "url": "https://wwwmpa.mpa-garching.mpg.de/mpa/publications/preprints/pp2013/MPA3537.pdf" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/smc/object.json b/src/objects/smc/object.json index fdb4f17fba4..80a5b81f026 100644 --- a/src/objects/smc/object.json +++ b/src/objects/smc/object.json @@ -71,7 +71,47 @@ ], "metersPerUnit": 30856775814913670000, "bodyRadiusM": 123427103259654680000 - } + }, + "facts": [ + { + "id": "distance", + "label": "Distance from the Sun", + "value": "62.44 kpc (±470 pc statistical, ±810 pc systematic)", + "note": "eclipsing-binaries", + "source": { + "label": "Graczyk et al. (2020), ApJ904,13; DOI 10.3847/1538-4357/abbb2b", + "url": "https://arxiv.org/abs/2010.08754" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 16.25°, Dec -72.42°", + "source": { + "label": "Stanimirovic2004ApJ...604..176S: A New Look at the Kinematics of Neutral Hydrogen in the Small Magellanic Cloud", + "url": "https://ui.adsabs.harvard.edu/abs/2004ApJ...604..176S" + } + }, + { + "id": "membership", + "label": "Membership", + "value": "Local group, Milky way", + "note": "Author catalogue host association: smc -> lmc -> mw; object confirmation is recorded separately.", + "source": { + "label": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1.", + "url": "https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv" + } + }, + { + "id": "half-light-radius", + "label": "Half-light radius", + "value": "1.079 kpc", + "source": { + "label": "Munoz2018ApJ...860...66M: A MegaCam Survey of Outer Halo Satellites. III. Photometric and Structural Parameters", + "url": "https://ui.adsabs.harvard.edu/abs/2018ApJ...860...66M/abstract" + } + } + ] }, "prepared": { "format": "cssearth-volume-datasets@1", diff --git a/src/objects/virgo-cluster/object.json b/src/objects/virgo-cluster/object.json index d6842c32c1e..cab328bc317 100644 --- a/src/objects/virgo-cluster/object.json +++ b/src/objects/virgo-cluster/object.json @@ -41,7 +41,56 @@ ], "metersPerUnit": 5.0140062462525665e+22, "bodyRadiusM": 5.0140062462525665e+22 - } + }, + "facts": [ + { + "id": "distance", + "label": "Group distance", + "value": "16.2 Mpc", + "note": "Cosmicflows-4 group distance: the weighted average of its members' distance moduli on the calibrated scale (table 3, DMzp).", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/viz-bin/cat/J/ApJ/944/94" + } + }, + { + "id": "position", + "label": "Sky position (J2000)", + "value": "RA 187.7°, Dec 12.34°", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + }, + { + "id": "classification", + "label": "Classification", + "value": "X-ray selected galaxy cluster", + "note": "MCXC-II R500 is an X-ray-derived overdensity aperture (500 times the critical density), not the cluster boundary or member distribution.", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + }, + { + "id": "redshift", + "label": "Redshift", + "value": "0.0036", + "source": { + "label": "Redshift reference 1995ApJ...446....1S, transcribed from MCXC-II.", + "url": "https://ui.adsabs.harvard.edu/abs/1995ApJ...446....1S" + } + }, + { + "id": "aperture", + "label": "R500 radius", + "value": "1.079 Mpc", + "source": { + "label": "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", + "url": "https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz" + } + } + ] }, "prepared": { "format": "cssearth-catalogue-points-bin@1", From 423f5bb457a2d9027ee87f36331662d132a90567 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 1 Oct 2026 11:22:11 -0300 Subject: [PATCH 11/23] refactor: galaxies, clusters and nebulae are ordinary scenes; delete the focus path --- CONTRIBUTING.md | 4 +- docs/page-navigation-transport.md | 12 +- package.json | 3 +- packages/objects/src/image-layer-bank.ts | 4 +- .../src/node/prepared-registry.test.ts | 9 +- .../objects/src/node/prepared-registry.ts | 9 +- packages/objects/src/registry/index.ts | 6 +- ...ed-focus-object.ts => navigable-object.ts} | 20 +- .../objects/src/registry/object-catalog.ts | 15 +- .../objects/src/registry/object-facts.test.ts | 21 - packages/objects/src/registry/object-facts.ts | 35 -- packages/renderer/src/navigation/index.ts | 1 - .../renderer/src/navigation/object-orbit.ts | 33 +- .../src/navigation/perspective-dolly.ts | 4 +- .../src/navigation/prepared-camera.ts | 82 +-- .../src/navigation/prepared-focus.test.ts | 224 -------- .../renderer/src/navigation/prepared-focus.ts | 94 ---- .../src/rendering/object-control-binding.ts | 2 +- .../renderer/src/runtime/object-runtime.ts | 9 - .../src/runtime/world-navigation-types.ts | 4 - .../src/sky/prepared-sky-runtime.test.ts | 24 +- .../src/universe/detailed-focus-context.ts | 5 +- .../src/universe/prepared-universe-runtime.ts | 42 +- .../src/universe/universe-catalog-banks.ts | 9 +- .../test/node/object-runtime.test.mts | 2 +- site/application-world-context.mts | 14 +- site/application-world-resources.mts | 8 +- site/build/prepare/prepare-bank-scene.mts | 135 +++++ site/build/prepare/prepare-catalog.mts | 4 +- site/build/prepare/prepare-facilities.mts | 2 +- ...us-pages.mts => prerender-level-pages.mts} | 10 +- site/components/DatasetList.astro | 19 +- site/components/HostedObjectFacts.astro | 15 - site/components/ObjectResult.astro | 3 +- site/components/ObjectShell.astro | 30 +- site/components/OverviewCard.astro | 9 +- site/components/PreparedFocusDatasets.astro | 59 --- site/components/SequencePlayer.astro | 8 +- site/dataset-response.mts | 26 +- site/dataset-url.mts | 15 +- ...atalog-data.mts => dot-catalogue-data.mts} | 2 +- .../{focus-catalog.mts => dot-catalogues.mts} | 4 +- site/focus-fragment.mts | 33 -- site/focus-response.mts | 90 ---- site/layouts/ObjectLayout.astro | 5 +- site/navigation/navigation-content.mts | 4 +- site/navigation/navigation-fragments.mts | 1 - site/navigation/navigation-history.mts | 6 - site/navigation/navigation-request.mts | 19 +- site/navigation/navigation-scope.mts | 45 +- site/object-browser.mts | 21 +- site/object-page-data.mts | 6 +- site/page-datasets.mts | 2 +- site/pages/catalogues/[id].json.ts | 4 +- site/pages/focus-fragment/index.astro | 8 - site/pages/sitemap.xml.ts | 7 +- site/prepared-context-navigation.mts | 163 ------ site/prepared-focus-card.mts | 111 ---- site/prepared-focus-target.mts | 43 -- site/prepared-focus.mts | 30 -- site/prepared-panel-content.mts | 6 + site/prepared-world-navigation.mts | 2 - site/scene/scene-publication.mts | 2 +- site/scene/scene-router.mts | 71 +-- site/scene/scene-selection.mts | 58 +- site/scene/scene-transition.mts | 35 +- site/scene/scene-view.mts | 30 +- site/scene/scene-world.mts | 15 +- site/search/catalogue-index.mts | 11 +- site/search/object-result.mts | 3 +- site/search/prepared-catalogue-index.mts | 50 +- site/selection-presentation.mts | 29 +- site/seo.mts | 5 - site/server/overview-result.mts | 2 +- site/server/search-response.mts | 7 +- site/shell/object-shell-client.mts | 40 +- site/shell/settings-panel.css | 12 +- site/shell/shell-layout.css | 5 - site/test/catalogue-window.test.mts | 13 +- site/test/focus-fragment.test.mts | 33 -- site/test/galactic-focus.test.mts | 7 +- site/test/navigation-content.test.mts | 8 +- site/test/navigation-fragments.test.mts | 5 - site/test/navigation-ontology.test.mts | 22 +- site/test/navigation-scope.test.mts | 16 +- site/test/navigation-test-values.mts | 2 - site/test/nebula-search.test.mts | 6 +- site/test/object-directory.test.mts | 6 +- site/test/object-result.test.mts | 3 +- site/test/page-datasets.test.mts | 4 +- .../test/prepared-context-navigation.test.mts | 494 ------------------ site/test/prepared-focus-card.test.mts | 175 ------- site/test/prepared-world-navigation.test.mts | 21 +- site/test/search-response.test.mts | 40 +- site/test/surface-label-loop.test.mts | 2 +- site/test/view-readout.test.mts | 8 +- site/view-readout.mts | 19 +- src/objects/fornax-cluster/inventory.json | 30 ++ src/objects/fornax-cluster/object.json | 86 +-- .../fornax-cluster/source/content/object.json | 102 ++++ src/objects/fornax-cluster/text.json | 34 ++ src/objects/helix/inventory.json | 24 + src/objects/helix/object.json | 66 +-- src/objects/helix/source/content/object.json | 74 +++ src/objects/helix/text.json | 45 ++ src/objects/lmc/inventory.json | 24 + src/objects/lmc/object.json | 74 +-- src/objects/lmc/source/content/object.json | 97 ++++ src/objects/lmc/text.json | 45 ++ src/objects/local-group/object.json | 34 +- src/objects/m1/inventory.json | 24 + src/objects/m1/object.json | 62 +-- src/objects/m1/source/content/object.json | 46 ++ src/objects/m1/text.json | 60 +++ src/objects/m2-9/inventory.json | 24 + src/objects/m2-9/object.json | 62 +-- src/objects/m2-9/source/content/object.json | 74 +++ src/objects/m2-9/text.json | 35 ++ src/objects/m31/inventory.json | 24 + src/objects/m31/object.json | 67 +-- src/objects/m31/source/content/object.json | 74 +++ src/objects/m31/text.json | 35 ++ src/objects/m33/inventory.json | 24 + src/objects/m33/object.json | 65 +-- src/objects/m33/source/content/object.json | 74 +++ src/objects/m33/text.json | 35 ++ src/objects/m42/inventory.json | 24 + src/objects/m42/object.json | 64 +-- src/objects/m42/source/content/object.json | 74 +++ src/objects/m42/text.json | 40 ++ src/objects/m45/inventory.json | 24 + src/objects/m45/object.json | 64 +-- src/objects/m45/source/content/object.json | 46 ++ src/objects/m45/text.json | 55 ++ src/objects/m8/inventory.json | 24 + src/objects/m8/object.json | 68 +-- src/objects/m8/source/content/object.json | 46 ++ src/objects/m8/text.json | 45 ++ src/objects/m81/inventory.json | 24 + src/objects/m81/object.json | 52 +- src/objects/m81/source/content/object.json | 63 +++ src/objects/m81/text.json | 35 ++ src/objects/m83/inventory.json | 24 + src/objects/m83/object.json | 53 +- src/objects/m83/source/content/object.json | 63 +++ src/objects/m83/text.json | 35 ++ src/objects/m87/inventory.json | 24 + src/objects/m87/object.json | 64 +-- src/objects/m87/source/content/object.json | 80 +++ src/objects/m87/text.json | 35 ++ src/objects/ngc-253/inventory.json | 24 + src/objects/ngc-253/object.json | 54 +- .../ngc-253/source/content/object.json | 63 +++ src/objects/ngc-253/text.json | 35 ++ src/objects/ngc-300/inventory.json | 24 + src/objects/ngc-300/object.json | 77 +-- .../ngc-300/source/content/object.json | 97 ++++ src/objects/ngc-300/text.json | 35 ++ src/objects/omega-centauri/inventory.json | 24 + src/objects/omega-centauri/object.json | 68 +-- .../omega-centauri/source/content/object.json | 80 +++ src/objects/omega-centauri/text.json | 40 ++ src/objects/smc/inventory.json | 24 + src/objects/smc/object.json | 71 +-- src/objects/smc/source/content/object.json | 97 ++++ src/objects/smc/text.json | 55 ++ src/objects/virgo-cluster/inventory.json | 30 ++ src/objects/virgo-cluster/object.json | 86 +-- .../virgo-cluster/source/content/object.json | 102 ++++ src/objects/virgo-cluster/text.json | 34 ++ 170 files changed, 3467 insertions(+), 3200 deletions(-) rename packages/objects/src/registry/{prepared-focus-object.ts => navigable-object.ts} (80%) delete mode 100644 packages/objects/src/registry/object-facts.test.ts delete mode 100644 packages/objects/src/registry/object-facts.ts delete mode 100644 packages/renderer/src/navigation/prepared-focus.test.ts delete mode 100644 packages/renderer/src/navigation/prepared-focus.ts create mode 100644 site/build/prepare/prepare-bank-scene.mts rename site/build/{prerender-focus-pages.mts => prerender-level-pages.mts} (85%) delete mode 100644 site/components/HostedObjectFacts.astro delete mode 100644 site/components/PreparedFocusDatasets.astro rename site/{focus-catalog-data.mts => dot-catalogue-data.mts} (96%) rename site/{focus-catalog.mts => dot-catalogues.mts} (83%) delete mode 100644 site/focus-fragment.mts delete mode 100644 site/focus-response.mts delete mode 100644 site/pages/focus-fragment/index.astro delete mode 100644 site/prepared-context-navigation.mts delete mode 100644 site/prepared-focus-card.mts delete mode 100644 site/prepared-focus-target.mts delete mode 100644 site/prepared-focus.mts delete mode 100644 site/test/focus-fragment.test.mts delete mode 100644 site/test/prepared-context-navigation.test.mts delete mode 100644 site/test/prepared-focus-card.test.mts create mode 100644 src/objects/fornax-cluster/source/content/object.json create mode 100644 src/objects/fornax-cluster/text.json create mode 100644 src/objects/helix/source/content/object.json create mode 100644 src/objects/helix/text.json create mode 100644 src/objects/lmc/source/content/object.json create mode 100644 src/objects/lmc/text.json create mode 100644 src/objects/m1/source/content/object.json create mode 100644 src/objects/m1/text.json create mode 100644 src/objects/m2-9/source/content/object.json create mode 100644 src/objects/m2-9/text.json create mode 100644 src/objects/m31/source/content/object.json create mode 100644 src/objects/m31/text.json create mode 100644 src/objects/m33/source/content/object.json create mode 100644 src/objects/m33/text.json create mode 100644 src/objects/m42/source/content/object.json create mode 100644 src/objects/m42/text.json create mode 100644 src/objects/m45/source/content/object.json create mode 100644 src/objects/m45/text.json create mode 100644 src/objects/m8/source/content/object.json create mode 100644 src/objects/m8/text.json create mode 100644 src/objects/m81/source/content/object.json create mode 100644 src/objects/m81/text.json create mode 100644 src/objects/m83/source/content/object.json create mode 100644 src/objects/m83/text.json create mode 100644 src/objects/m87/source/content/object.json create mode 100644 src/objects/m87/text.json create mode 100644 src/objects/ngc-253/source/content/object.json create mode 100644 src/objects/ngc-253/text.json create mode 100644 src/objects/ngc-300/source/content/object.json create mode 100644 src/objects/ngc-300/text.json create mode 100644 src/objects/omega-centauri/source/content/object.json create mode 100644 src/objects/omega-centauri/text.json create mode 100644 src/objects/smc/source/content/object.json create mode 100644 src/objects/smc/text.json create mode 100644 src/objects/virgo-cluster/source/content/object.json create mode 100644 src/objects/virgo-cluster/text.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a8365d1f7a..37de2386771 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,9 @@ merge**, and the tracked inventory must be part of the diff. Git holds the inventory; R2 holds the bytes. 1. Bake the object. `pnpm prepare:objects --object=` for a body, - `pnpm prepare:volume src/objects/` for a volume field. From a clean + `pnpm prepare:volume src/objects/` for a volume field, + `pnpm prepare:bank-scene ` for the scene of a package whose subject is a + bank (a galaxy's image layers, a nebula's volume, a cluster's dots). From a clean checkout, restore that object's sources with `node packages/bake/cli/restore-source-inputs.mts --object=` first. 2. Publish the bytes: `node packages/bake/cli/publish-runtime-assets.mts --object=`. diff --git a/docs/page-navigation-transport.md b/docs/page-navigation-transport.md index 62b9e0cae14..63fc4ea2be0 100644 --- a/docs/page-navigation-transport.md +++ b/docs/page-navigation-transport.md @@ -109,12 +109,12 @@ 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 diff --git a/package.json b/package.json index a7d4acb73dd..1471444ef9f 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", @@ -63,6 +63,7 @@ "prepare:typecheck": "node .github/scripts/ci/prepare-typecheck.mts", "prepare:titles": "node site/build/prepare/prepare-shell-titles.mts", "prepare:volume": "node packages/bake/cli/prepare-volume.mts", + "prepare:bank-scene": "node site/build/prepare/prepare-bank-scene.mts", "prepare:world-presentation": "node site/build/prepare/prepare-moon-labels.mts && node site/build/prepare/prepare-world-presentation.mts", "prepare:world-context": "node site/build/prepare/prepare-spatial-context.ts src/objects/sun/source/navigation/universe.json src/objects/sun/prepared/world-context.json && pnpm prepare:world-presentation", "pretest": "pnpm prepare:shell", diff --git a/packages/objects/src/image-layer-bank.ts b/packages/objects/src/image-layer-bank.ts index 95e29830030..2660d0c5589 100644 --- a/packages/objects/src/image-layer-bank.ts +++ b/packages/objects/src/image-layer-bank.ts @@ -16,8 +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; - // `catalog` and `worldFrame` are the package's registry entry and placement (prepared-focus-object.ts), read by the registry. - const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'catalog', 'worldFrame', 'facts'].includes(key)); + // `catalog` and `worldFrame` are the package's registry entry and placement (navigable-object.ts), read by the registry. + const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'catalog', 'worldFrame', 'page', 'scene'].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) { diff --git a/packages/objects/src/node/prepared-registry.test.ts b/packages/objects/src/node/prepared-registry.test.ts index a3afc82574b..76185943be9 100644 --- a/packages/objects/src/node/prepared-registry.test.ts +++ b/packages/objects/src/node/prepared-registry.test.ts @@ -6,7 +6,7 @@ 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'; -import { isHostedObject, isOverviewObject } from '../registry/index.js'; +import { isOverviewObject, isSceneObject } from '../registry/index.js'; // Temporary checkouts the tests make, removed once the file's tests finish. const temporary: string[] = []; @@ -60,7 +60,7 @@ 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'), /no scene of its own/); + assert.throws(() => registry.requireSceneObject('helix'), /no body of its own/); // A hosted package is a body of the world as a scene is, with its identity from its own descriptor. assert.deepEqual(registry.worldObjects.map(object => object.id), ['sun', 'mars', 'helix']); const helix = registry.objects.find(object => object.id === 'helix')!; @@ -71,9 +71,10 @@ test('reads the registry in catalogue order, with distances, discoveries, prepar const placed = readPreparedObjects(await checkout({ overviews: [overview, placedLevel] })); assert.deepEqual(placed.worldObjects.map(object => object.id), ['sun', 'mars', 'helix', 'local-group']); assert.deepEqual(placed.objects.filter(isOverviewObject).map(object => object.id), ['milky-way', 'local-group']); - assert.equal(isHostedObject(helix), true); + // A bank package whose body-less scene is not prepared yet is drawn by the world's host. + assert.equal(isSceneObject(helix), false); assert.partialDeepStrictEqual(helix, { classification: 'nebula', route: '/helix/', sceneHostId: 'sun', systemName: 'Solar System' }); - assert.throws(() => registry.requireSceneObject('milky-way'), /no scene of its own/); + assert.throws(() => registry.requireSceneObject('milky-way'), /no body of its own/); assert.throws(() => registry.requireSceneObject('pluto'), /Unknown/); }); diff --git a/packages/objects/src/node/prepared-registry.ts b/packages/objects/src/node/prepared-registry.ts index 67d054aa7ec..a71a62c8ff2 100644 --- a/packages/objects/src/node/prepared-registry.ts +++ b/packages/objects/src/node/prepared-registry.ts @@ -4,7 +4,7 @@ import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import { isRecord } from '@cssearth/core'; -import { catalogueObject, defineObjects, isPlacedObject, isSceneObject } from '../registry/index.js'; +import { catalogueObject, defineObjects, isHostedDescriptor, isPlacedObject, isSceneObject } from '../registry/index.js'; import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, PlacedFields } from '../registry/index.js'; /** The catalogue files, relative to the checkout. `entries` is a module: each scene object's descriptor, as its folder's @@ -68,7 +68,10 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { }); 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)); + // Preparation's scene objects are the bodies its lanes bake. A bank package's body-less scene is written from its bank + // (site/build/prepare/prepare-bank-scene.mts), so no body lane, marker or recipe reads it as one. + const banks = new Set(entries.flatMap(entry => isHostedDescriptor(entry.descriptor) && isRecord(entry.descriptor) ? [entry.descriptor.id] : [])); + const sceneObjects = Object.freeze(objects.filter(isSceneObject).filter(object => !banks.has(object.id))); for (const object of objects) if (!isSceneObject(object) && !sceneObjects.some(host => host.id === object.sceneHostId)) { throw new TypeError(`Scene host is not a registered scene: ${object.id}`); } @@ -77,7 +80,7 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects, 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} has no scene of its own.`); + if (!isSceneObject(object) || banks.has(id)) throw new TypeError(`Object ${id} has no body of its own to prepare.`); return object; } }); } diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index b8b35f83851..0924c8d13c2 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -11,15 +11,13 @@ export { distanceDescription, parseNavigationDistance } from './navigation-dista export type { NavigationDistance } from './navigation-distance.js'; export { catalogEntry, catalogueObject } from './object-catalog.js'; export type { CatalogContext, CatalogEntry } from './object-catalog.js'; -export { objectFacts } from './object-facts.js'; -export type { ObjectFact } from './object-facts.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, EXTENDED_CLASSIFICATIONS, isExtendedClassification, isPlacedClassification, OBJECT_CLASSIFICATIONS, PLACED_CLASSIFICATIONS } from './object-schema.js'; export type { ObjectClassification, ObjectDefinitionInput, ObjectEntry, ObjectPositionM, ObjectWorldFrame } from './object-schema.js'; -export { destinationSearchNames, HOSTED_OBJECT_TYPES, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, isSceneObject } from './prepared-focus-object.js'; -export type { NavigableObject, PlacedFields, PreparedFocusObject } from './prepared-focus-object.js'; +export { destinationSearchNames, HOSTED_OBJECT_TYPES, isHostedDescriptor, isOverviewObject, isPlacedObject, isSceneObject, sceneDescriptorOf } from './navigable-object.js'; +export type { NavigableObject, PlacedFields, PlacedObject } 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'; diff --git a/packages/objects/src/registry/prepared-focus-object.ts b/packages/objects/src/registry/navigable-object.ts similarity index 80% rename from packages/objects/src/registry/prepared-focus-object.ts rename to packages/objects/src/registry/navigable-object.ts index 7b189029ff8..eb097be9de5 100644 --- a/packages/objects/src/registry/prepared-focus-object.ts +++ b/packages/objects/src/registry/navigable-object.ts @@ -25,7 +25,7 @@ export interface NavigableObject { readonly worldFrame?: ObjectWorldFrame; readonly discovery?: Readonly; readonly aliases?: readonly string[]; - /** The names an object without a scene is found by. */ + /** The names an object with alternate names is found by: its id, its name and each alias. */ readonly searchNames?: readonly string[]; /** A scene of its own; without one the object is drawn by `sceneHostId`'s scene. */ readonly loadScene?: ObjectDefinitionInput['loadScene']; @@ -38,8 +38,8 @@ export interface NavigableObject { readonly originM?: readonly [number, number, number]; } -/** A placed object the world's host draws: a galaxy, a cluster of galaxies, a nebula or a globular cluster with a package. */ -export interface PreparedFocusObject { +/** The fields an object with a place in the world carries. */ +export interface PlacedObject { readonly id: string; readonly name: string; readonly aliases: readonly string[]; @@ -59,17 +59,21 @@ export interface PreparedFocusObject { export const HOSTED_OBJECT_TYPES: readonly string[] = Object.freeze(['image-layer-bank', 'volume-dataset-bank', 'catalogue-point-bank']); export const isHostedDescriptor = (descriptor: unknown): boolean => isRecord(descriptor) && typeof descriptor.type === 'string' && HOSTED_OBJECT_TYPES.includes(descriptor.type); +/** A bank package's descriptor as its scene reads it: `prepared` names the scene's transport (`properties.scene`), where the + * package's own `prepared` names its bank. Any other descriptor is returned as it is. */ +export function sceneDescriptorOf(descriptor: Descriptor): Descriptor { + if (!isHostedDescriptor(descriptor) || !isRecord(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.scene)) return descriptor; + // The scene is an ordinary layered body with no layers of its own: the renderer mounts it as it mounts any body. + return { ...descriptor, type: 'layered-body', prepared: descriptor.properties.scene } as Descriptor; +} + /** The object mounts a scene of its own. */ export const isSceneObject = (object: NavigableObject): object is ObjectEntry => typeof object.loadScene === 'function'; /** The object is a level of the zoom ladder. */ export const isOverviewObject = (object: NavigableObject | NavigableObject): object is OverviewObject => (object as NavigableObject).zoom !== undefined; /** The object has a place in the world: the world context draws, names and selects it. */ export const isPlacedObject = (object: NavigableObject): object is NavigableObject & PlacedFields => object.worldFrame !== undefined; -export type PlacedFields = Pick; -/** A placed object with neither a scene nor a level: its page is the host's scene framed on it. */ -export const isHostedObject = (object: NavigableObject): object is PreparedFocusObject => - object.worldFrame !== undefined && typeof object.loadScene !== 'function' && object.zoom === undefined; - +export type PlacedFields = Pick; /** The names an object without a scene 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); diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index e856b27b63e..381cc9604aa 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -6,8 +6,8 @@ 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 { destinationSearchNames, isHostedDescriptor } from './prepared-focus-object.js'; -import type { NavigableObject } from './prepared-focus-object.js'; +import { destinationSearchNames, isHostedDescriptor, sceneDescriptorOf } from './navigable-object.js'; +import type { NavigableObject } from './navigable-object.js'; import { overviewLevel } from './overview-object.js'; /** `orbitsWithinAu`: a host's authored presentation range, the camera distance up to which its system draws every orbit. */ @@ -84,18 +84,21 @@ export function catalogueObject(value: unknown, loadScene: (descriptor: Record) => ObjectDefinitionInput['loadScene']): NavigableObject { 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, properties = value.descriptor.properties, id = value.descriptor.id, level = overviewLevel(descriptor); - const placed = properties.catalog !== undefined, scene = placed && level === null && !isHostedDescriptor(descriptor); + // A bank package has a scene of its own once its body-less scene is prepared (`properties.scene`). + const placed = properties.catalog !== undefined, scene = placed && level === null && (!isHostedDescriptor(descriptor) || properties.scene !== undefined); if (!placed && level === null) throw new TypeError(`Invalid catalogue entry: ${id} has neither a catalogue entry nor an overview.`); if (!scene && (typeof value.sceneHostId !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(value.sceneHostId))) throw new TypeError(`Invalid catalogue entry: ${id} names no scene host.`); - const entry = placed ? catalogEntry(descriptor, scene ? loadScene(descriptor) : async () => { throw new Error(`${id} has no scene of its own.`); }, + const entry = placed ? catalogEntry(descriptor, scene ? loadScene(sceneDescriptorOf(descriptor)) : async () => { throw new Error(`${id} has no scene of its own.`); }, parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)) : null; - if (entry && scene) { const { order: _order, context: _context, ...object } = entry; return object; } + // An object with alternate names is found by them: search matches its id, its name and each alias. + const searchNames = entry?.aliases.length ? { searchNames: Object.freeze(destinationSearchNames([id, entry.name, ...entry.aliases])) } : {}; + if (entry && scene) { const { order: _order, context: _context, ...object } = entry; return { ...object, ...searchNames }; } const { name: levelName, description: levelDescription, ...ladder } = level ?? {}; const name = entry?.name ?? levelName, description = entry?.description ?? levelDescription; if (name === undefined || description === undefined) throw new TypeError(`Invalid catalogue entry: ${id} has no name or description.`); return Object.freeze({ id, name, description, route: `/${id}/`, sceneHostId: value.sceneHostId as string, ...(entry ? { classification: entry.classification, ...(entry.classificationLabel === undefined ? {} : { classificationLabel: entry.classificationLabel }), systemName: entry.systemName, color: entry.color, distance: entry.distance, worldFrame: entry.worldFrame, discovery: entry.discovery, - aliases: entry.aliases, searchNames: Object.freeze(destinationSearchNames([id, entry.name, ...entry.aliases])) } : {}), + aliases: entry.aliases, ...searchNames } : {}), ...ladder }); } diff --git a/packages/objects/src/registry/object-facts.test.ts b/packages/objects/src/registry/object-facts.test.ts deleted file mode 100644 index 2ceeaf4839d..00000000000 --- a/packages/objects/src/registry/object-facts.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { objectFacts } from './object-facts.js'; - -const source = { label: 'A paper (2020)', url: 'https://example.org/paper' }; -const descriptor = (facts: unknown) => ({ schema: 'cssearth-object@2', id: 'm31', type: 'image-layer-bank', properties: { facts } }); - -test('reads the facts a package states, each with its source', () => { - assert.deepEqual(objectFacts(descriptor([{ id: 'distance', label: 'Distance', value: '776 kpc', note: 'RR Lyrae.', source }])), - [{ id: 'distance', label: 'Distance', value: '776 kpc', note: 'RR Lyrae.', source }]); - assert.deepEqual(objectFacts({ schema: 'cssearth-object@2', id: 'earth', properties: {} }), []); -}); - -test('refuses a fact without a source, a repeated fact and a field it does not know, naming the file', () => { - const fact = { id: 'distance', label: 'Distance', value: '776 kpc', source }; - assert.throws(() => objectFacts(descriptor([{ ...fact, source: undefined }])), /src\/objects\/m31\/object\.json: properties\.facts\[0\]\.source needs a label and an https url/); - assert.throws(() => objectFacts(descriptor([{ ...fact, source: { ...source, url: 'http://example.org' } }])), /https url/); - assert.throws(() => objectFacts(descriptor([fact, fact])), /properties\.facts\[1\] repeats the fact distance/); - assert.throws(() => objectFacts(descriptor([{ ...fact, unit: 'kpc' }])), /needs an id, a label and a value/); - assert.throws(() => objectFacts(descriptor({})), /is a list of facts/); -}); diff --git a/packages/objects/src/registry/object-facts.ts b/packages/objects/src/registry/object-facts.ts deleted file mode 100644 index 89b8e6f95a0..00000000000 --- a/packages/objects/src/registry/object-facts.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isRecord } from '@cssearth/core'; - -/** A fact a package states about itself for its card, with the publication it comes from. */ -export interface ObjectFact { - readonly id: string; - readonly label: string; - readonly value: string; - /** How the value was obtained or what it does not mean, in the source's terms. */ - readonly note?: string; - readonly source: { readonly label: string; readonly url: string }; -} - -/** The facts a package's descriptor states under `properties.facts`, for a package without a content recipe of its own (a - * galaxy, a cluster of galaxies, a nebula the world's host draws); empty when it states none. Each names its source. */ -export function objectFacts(descriptor: unknown): readonly ObjectFact[] { - if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.facts === undefined) return Object.freeze([]); - const id = String(descriptor.id), facts = descriptor.properties.facts; - if (!Array.isArray(facts)) throw new TypeError(`src/objects/${id}/object.json: properties.facts is a list of facts.`); - const seen = new Set(); - return Object.freeze(facts.map((fact: unknown, index) => { - const at = `src/objects/${id}/object.json: properties.facts[${index}]`; - if (!isRecord(fact) || Object.keys(fact).some(key => !['id', 'label', 'value', 'note', 'source'].includes(key)) - || typeof fact.id !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(fact.id) || typeof fact.label !== 'string' || !fact.label.trim() - || typeof fact.value !== 'string' || !fact.value.trim() || (fact.note !== undefined && (typeof fact.note !== 'string' || !fact.note.trim()))) { - throw new TypeError(`${at} needs an id, a label and a value, with an optional note and its source.`); - } - if (seen.has(fact.id)) throw new TypeError(`${at} repeats the fact ${fact.id}.`); - seen.add(fact.id); - const source = fact.source; - if (!isRecord(source) || Object.keys(source).some(key => key !== 'label' && key !== 'url') || typeof source.label !== 'string' || !source.label.trim() - || typeof source.url !== 'string' || !/^https:\/\//u.test(source.url)) throw new TypeError(`${at}.source needs a label and an https url; a fact without a source is not stated.`); - return Object.freeze({ id: fact.id, label: fact.label, value: fact.value, ...(fact.note === undefined ? {} : { note: fact.note as string }), - source: Object.freeze({ label: source.label, url: source.url }) }); - })); -} 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.ts b/packages/renderer/src/navigation/object-orbit.ts index 73ced315d1c..c49b60201f1 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; } @@ -212,10 +210,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')); @@ -233,11 +231,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(); @@ -296,27 +294,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); @@ -338,7 +315,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(); @@ -439,7 +415,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 1c6eebdbaa8..ccae2588979 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 @@ -140,63 +136,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; }, @@ -208,16 +149,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); @@ -230,8 +169,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() { @@ -246,19 +183,6 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon sunDirection: orientation.sunViewDirection(), counterRotationFor: orientation.captureCounterRotation() }; }, adopt, - 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 181067f7a07..00000000000 --- a/packages/renderer/src/navigation/prepared-focus.test.ts +++ /dev/null @@ -1,224 +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); -}); 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 1b65bb19d21..2fe6efe3032 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], @@ -646,13 +646,17 @@ test('authoritative detailed close-up gates background fetch, painting and publi 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 = '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: selecting that body is what draws its bank. + const select = (id: string) => { + 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 }); + }; 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('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']); @@ -670,7 +674,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}`); @@ -689,17 +693,15 @@ 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('image-bank', focus('image-bank')); mounted.publish(near, viewport, spatialFrame); + select('image-bank'); mounted.publish(near, 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('no-bank', focus('no-bank')); mounted.publish(near, viewport, spatialFrame); + select('no-bank'); mounted.publish(near, viewport, spatialFrame); assert.equal(mw.style.display, ''); - assert.throws(() => mounted.selectGalaxy('focus-bank', focus('mismatch')), /focus/); - assert.throws(() => mounted.selectGalaxy('catalogue-row', focus('catalogue-row')), /not a body of the prepared world context/); } finally { mounted.destroy(); } }); 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/prepared-universe-runtime.ts b/packages/renderer/src/universe/prepared-universe-runtime.ts index ab474a73d69..7bc49847225 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; @@ -207,7 +207,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol let selectedEdge: PreparedLabelEdge | undefined, previewEdge: PreparedLabelEdge | undefined; const caption = () => previewCaption ?? captionBody; const captionFlags = () => ({ overview: selectionPreview ? false : overview, focused: false, preview: selectionPreview, edge: previewCaption ? previewEdge : selectedEdge }); - let detailedFocus: { objectId: string; focus: PreparedNavigationFocus } | null = null; + let detailedFocus: { objectId: string; focus: SelectedBank } | null = null; const pointBankIds = new Set(pointBanks.map(bank => bank.id)); let selectedPoints: string | undefined; background.mount(); @@ -241,33 +241,6 @@ 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.'); - } - // A package the host draws is a body of this world: selecting it selects that body, as a scene's page selects its own. - const body = id === null ? sceneBody : plan.bodies.find(candidate => candidate.id === id); - if (!body) throw new TypeError(`Hosted object ${id} is not a body of the prepared world context.`); - const next = focus && id !== null && [...declaredImageLayers, ...volumeDatasetBanks].some(bank => bank.id === id) - ? { objectId: id, 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 while it is selected, without the detailed focus's context fade. - const points = id !== null && pointBankIds.has(id) ? id : undefined; - const pointsChanged = points !== selectedPoints; - selectedPoints = points; - const bodyChanged = body !== selected; - if (bodyChanged) { - selected = body; captionBody = body; selectedEdge = undefined; - selectedLabel.prepare(caption()); - spatial.selectObject(body.id); - publishSuppressedLabels(); - root.dataset.selectedObject = body.id; - } - if (pointsChanged || changed || bodyChanged) requestPublication?.(); - }, ensureGalaxyCatalog: catalogBanks.ensureCatalog, focusBank(id: string) { return datasets.focusBank(id) ?? catalogBanks.focusBank(id); }, setVolumeDatasetEnabled: datasets.setEnabled, @@ -319,6 +292,11 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol if (!(framingScale > 0 && framingScale <= 1)) throw new TypeError(`Selected ${id} has an invalid framing scale ${framingScale}.`); selected = sceneBody = body; selectedEdge = edge; + // A bank package draws its bank while it is the selected body: a galaxy's image layers or volume, a cluster's dots. + // Its framing radius is its world frame's, as a body's radius is. + detailedFocus = [...declaredImageLayers, ...volumeDatasetBanks].some(bank => bank.id === id) + ? { objectId: id, focus: { positionM: body.positionM as SelectedBank['positionM'], framingRadiusM: body.radiusM } } : null; + selectedPoints = pointBankIds.has(id) ? id : undefined; captionBody = framingScale === 1 ? body : Object.freeze({ ...body, radiusM: body.radiusM / framingScale }); selectedLabel.prepare(caption()); spatial.selectObject(id); diff --git a/packages/renderer/src/universe/universe-catalog-banks.ts b/packages/renderer/src/universe/universe-catalog-banks.ts index 0f0028eedd8..089ad304892 100644 --- a/packages/renderer/src/universe/universe-catalog-banks.ts +++ b/packages/renderer/src/universe/universe-catalog-banks.ts @@ -27,7 +27,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 }[]; @@ -37,7 +37,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). */ @@ -164,9 +163,9 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara publishImages(world: WorldCameraPose, viewport: WorldCameraViewport, volumeOpacity: number, detailedObjectId?: string) { 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/test/node/object-runtime.test.mts b/packages/renderer/test/node/object-runtime.test.mts index 7457b87b3a9..91a856e9888 100644 --- a/packages/renderer/test/node/object-runtime.test.mts +++ b/packages/renderer/test/node/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/site/application-world-context.mts b/site/application-world-context.mts index 5a1db9f0344..78f468e55c1 100644 --- a/site/application-world-context.mts +++ b/site/application-world-context.mts @@ -1,8 +1,3 @@ -import { isHostedObject } from '@cssearth/objects'; -import { knownObject, loadObject } from './object-directory.mts'; -import type { NavigableObject } from './objects.mts'; -/** The registry object a focus is on: a placed object the world's host draws. */ -const hostedFocus = (object: NavigableObject | null | undefined) => object != null && isHostedObject(object) ? object : null; import { createSceneLifetime } from '@cssearth/engine'; import { labelOcclusionFor } from '@cssearth/renderer'; import { afterStartup } from '@cssearth/renderer/rendering/startup-gate.ts'; @@ -13,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'; @@ -28,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(); @@ -54,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), - objects: { known: id => hostedFocus(knownObject(id)), load: id => loadObject(id).then(hostedFocus) }, 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 @@ -100,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 fe87ee0e88e..1fa75b9ee54 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'; @@ -47,9 +47,9 @@ type ApplicationUniverse = ReturnType & { 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; diff --git a/site/build/prepare/prepare-bank-scene.mts b/site/build/prepare/prepare-bank-scene.mts new file mode 100644 index 00000000000..bfec5568182 --- /dev/null +++ b/site/build/prepare/prepare-bank-scene.mts @@ -0,0 +1,135 @@ +// Entry: node site/build/prepare/prepare-bank-scene.mts [...]. A package whose subject is a bank (a galaxy's image +// layers, a nebula's volume, a cluster's member dots) has no surface to draw, so no body lane bakes it. This writes the +// same prepared 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 as the companions its bank already publishes, and its content and text from the +// authored files every package has. The world draws the bank; the scene is where the page stands. +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isHostedDescriptor } from '@cssearth/objects'; +import { isRecord } from '@cssearth/core'; +import { parsePreparedObjectRuntime, requireControls } from '@cssearth/renderer'; +import { CUBIC_SKY_CAMERA_PRESENTATION_STANDARD, prepareCubicSky } from '@cssearth/bake/presentation'; +import { prepareEclipticPresentationFrame, preparePhysicalWorldFrame, prepareSolarSystemScene, transform, transpose } from '@cssearth/bake/objects/scene'; +import { refreshPreparedInventory } from '@cssearth/bake/contract'; +import type { ObjectContentSource } from '@cssearth/bake/objects/content'; +import { prepareObjectContent } from '../content/prepare.ts'; +import { parseObjectText, PREPARED_TEXT_SCHEMA } from '../../object-text.mts'; + +const root = resolve(import.meta.dirname, '../../..'); +/** The scene's units: the framing radius is drawn as a sphere of this radius would be, so the shared camera frames it. */ +const RADIUS_UNITS = 248, GEOMETRY_SCALE = 1.25, DEFAULT_ZOOM = 1.25; +export const BANK_SCENE_PIN = Object.freeze({ format: 'cssearth-css-object@5', url: 'prepared/object.json' }); + +const json = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); + +export async function prepareBankScene(id: string, projectRoot = root) { + const directory = resolve(projectRoot, 'src/objects', id), descriptorPath = resolve(directory, 'object.json'); + const descriptor = await json(descriptorPath); + if (!isRecord(descriptor) || descriptor.id !== id || !isHostedDescriptor(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.catalog) + || !isRecord(descriptor.properties.worldFrame)) throw new TypeError(`src/objects/${id}/object.json: a bank scene needs a bank package with a catalogue entry and a world frame.`); + const framingRadiusM = descriptor.properties.worldFrame.bodyRadiusM; + if (typeof framingRadiusM !== 'number' || !(framingRadiusM > 0)) throw new TypeError(`src/objects/${id}/object.json: worldFrame.bodyRadiusM is its framing radius, a positive number of metres.`); + const solarGeometry = await import(pathToFileURL(resolve(projectRoot, 'src/platform/solar-geometry.mts')).href) as typeof import('../../../src/platform/solar-geometry.mts'); + const scene = await prepareSolarSystemScene(solarGeometry, { bodyId: id as never, bodyRadiusUnits: RADIUS_UNITS, bodyRadiusKilometers: framingRadiusM / 1000, + defaultZoom: DEFAULT_ZOOM, geometryScale: GEOMETRY_SCALE, light: 'self', + starfield: prepareCubicSky({ objectId: id, cameraContract: CUBIC_SKY_CAMERA_PRESENTATION_STANDARD }) }); + // The world frame as the world-navigation stage gives every body: the ecliptic presentation frame at its place, with the + // framing radius where a body's radius would be. The sky rides that frame. + const ecliptic = prepareEclipticPresentationFrame(solarGeometry, id); + const bodyToReference = solarGeometry.requireBodyFixedToIcrf(id), distanceM = solarGeometry.requireBodyOrbit(id).heliocentricDistanceAu * solarGeometry.ASTRONOMICAL_UNIT_KILOMETERS * 1000; + const frame = preparePhysicalWorldFrame({ referenceFrame: 'sun-icrf', epochJdTt: solarGeometry.SOLAR_GEOMETRY_EPOCH_JD_TT, + originM: transform(bodyToReference as never, solarGeometry.requireBodyFixedSunDirection(id) as never).map(component => -component * distanceM) as never, + bodyToReference: bodyToReference as never, bodyToPresentation: ecliptic.basis.flat() as never, + orbitUpReference: transform(bodyToReference as never, solarGeometry.requireBodyFixedEclipticNorth(id) as never), + physicalRadiusM: framingRadiusM, renderedRadiusUnits: RADIUS_UNITS * GEOMETRY_SCALE }); + const registration = transpose(frame.presentationToReference); + const sky = { ...scene.starfield, sceneRegistration: `matrix3d(${[registration[0], registration[3], registration[6], 0, registration[1], registration[4], registration[7], 0, + registration[2], registration[5], registration[8], 0, 0, 0, 0, 1].join(',')})`, sceneRegistrationModel: 'icrf-in-authored-presentation-frame', sceneRegistrationEpoch: solarGeometry.SOLAR_GEOMETRY_EPOCH_LABEL }; + const source = await json(resolve(directory, 'source/content/object.json')) as ObjectContentSource; + const content = prepareObjectContent(source); + const text = parseObjectText(await json(resolve(directory, 'text.json')), id); + // Its datasets are the ones its bank publishes: each shows that bank's dataset, as a star's disc dataset shows its volume. + // A package of catalogue dots has one dataset, the dots themselves, shown by a picture of them as they lie on the sky. + const presentation = descriptor.type === 'catalogue-point-bank' + ? { defaultDataset: 'members', controls: [{ id: 'members', label: 'Member galaxies', thumbnailUrl: await memberDotsThumbnail(id, directory, projectRoot) }] } + : await json(resolve(directory, 'prepared/presentation.json')); + if (!isRecord(presentation) || typeof presentation.defaultDataset !== 'string' || !Array.isArray(presentation.controls)) throw new TypeError(`src/objects/${id}/prepared/presentation.json: a bank's presentation lists its datasets and names the default.`); + const published = presentation.controls.filter(isRecord); + const datasets = { title: content.datasets.title, defaultDataset: presentation.defaultDataset, + controls: published.map(({ title: _title, summary: _summary, detail: _detail, description: _description, ...control }) => + // A volume bank holds several datasets and the shared dataset selection switches among them; an image-layer bank and a + // bank of dots are drawn whole while the object is selected. + ({ ...control, id: String(control.id), ...(descriptor.type === 'volume-dataset-bank' ? { volume: { objectId: id, datasetId: String(control.id), surface: String(control.id) } } : {}) })) }; + for (const control of datasets.controls) if (!text.datasets[control.id]) throw new TypeError(`src/objects/${id}/text.json: dataset ${control.id} has no reader text.`); + const controls = { datasets, settings: content.settings }; + requireControls(controls); + 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: 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 }); + const prepared = resolve(directory, 'prepared'); + await writeFile(resolve(prepared, 'runtime.json'), `${JSON.stringify(definition)}\n`); + await writeFile(resolve(prepared, 'controls.json'), `${JSON.stringify(controls)}\n`); + await writeFile(resolve(prepared, 'content.json'), `${JSON.stringify({ schema: 'cssearth-prepared-content@2', objectId: id, title: content.title, facts: content.facts, moreFacts: content.moreFacts, + charts: [], galleries: [], resources: content.resources, provenance: source.provenance })}\n`); + await writeFile(resolve(prepared, 'text.json'), `${JSON.stringify({ schema: PREPARED_TEXT_SCHEMA, objectId: id, card: text.card, introduction: text.introduction, datasets: text.datasets })}\n`); + // The descriptor takes the frame the scene was prepared in and names the scene's transports, as every body's does. + const properties = { ...descriptor.properties, worldFrame: frame, page: { stylesheets: ['src/renderers/css/styles/body-surfaces.css'], metadata: { url: 'prepared/page.json' } }, scene: BANK_SCENE_PIN, + catalog: { ...descriptor.properties.catalog, description: text.card.text } }; + await writeFile(descriptorPath, `${JSON.stringify({ ...descriptor, properties }, null, 2)}\n`); + await refreshPreparedInventory(id, projectRoot); + // A bank of dots publishes no image of its own; the picture of its dots written here is its one public file. + if (descriptor.type === 'catalogue-point-bank') { + const { inventoryPublicAssets } = await import('@cssearth/objects/node'); + await inventoryPublicAssets({ objectId: id, objectDirectory: directory, urls: datasets.controls.map(control => String((control as { thumbnailUrl?: unknown }).thumbnailUrl)), + publicRoot: resolve(projectRoot, 'public/scenes', id) }); + } + return { id, datasets: datasets.controls.length, facts: content.facts.length }; +} + +/** 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. */ +async function memberDotsThumbnail(id: string, directory: string, projectRoot: 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(directory, 'prepared/dots.bin'))), `src/objects/${id}/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(`src/objects/${id}/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'); + const output = resolve(projectRoot, 'public/scenes', id), filename = `${id}-dataset-members.webp`; + await mkdir(output, { recursive: true }); + await sharp(pixels, { raw: { width: SIZE, height: SIZE, channels: 3 } }).webp({ lossless: true }).toFile(resolve(output, filename)); + return `/scenes/${id}/${filename}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const named = process.argv.slice(2); + const ids = named.length ? named : (await Promise.all((await readdir(resolve(root, 'src/objects'))).map(async id => { + const descriptor = await json(resolve(root, 'src/objects', id, 'object.json')).catch(() => null); + return isHostedDescriptor(descriptor) && isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.catalog !== undefined ? id : null; + }))).filter((id): id is string => id !== null); + for (const id of ids) console.log(JSON.stringify(await prepareBankScene(id))); +} diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index 43b1bdc0fb7..b59210a33b8 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -162,7 +162,9 @@ export async function prepareCatalog({ projectRoot = root } = {}) { // One row shape for every object: its descriptor, its distance and discovery when placed, its host when it has no scene. await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.entries), preparedCatalogueModule(entries.map(({ id, distance }, index) => { const descriptor = descriptors.get(id); - return { descriptor, distance, discovery: discoveries[index]!, ...(isHostedDescriptor(descriptor) ? { sceneHostId: host.id } : {}) }; + // A bank package whose body-less scene is not prepared yet is drawn by the world's host. + const sceneless = isHostedDescriptor(descriptor) && isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.scene === undefined; + return { descriptor, distance, discovery: discoveries[index]!, ...(sceneless ? { sceneHostId: host.id } : {}) }; }))); // 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 }) => { 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/prerender-focus-pages.mts b/site/build/prerender-level-pages.mts similarity index 85% rename from site/build/prerender-focus-pages.mts rename to site/build/prerender-level-pages.mts index 67509482808..44ff8204537 100644 --- a/site/build/prerender-focus-pages.mts +++ b/site/build/prerender-level-pages.mts @@ -1,10 +1,10 @@ import { isSceneObject } from '@cssearth/objects'; -// 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'; 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/HostedObjectFacts.astro b/site/components/HostedObjectFacts.astro deleted file mode 100644 index 4adc16f0a7d..00000000000 --- a/site/components/HostedObjectFacts.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import { objectFacts } from '@cssearth/objects'; -import { CONTEXT_OBJECT_DESCRIPTORS } from '../prepared-context-objects.mts'; -import ObjectFactsheet from './ObjectFactsheet.astro'; -// Each package the world's host draws states its own facts (object.json `properties.facts`), each with its source. -const packages = Object.values(CONTEXT_OBJECT_DESCRIPTORS).map(descriptor => ({ id: (descriptor as { id: string }).id, facts: objectFacts(descriptor) })) - .filter(({ facts }) => facts.length > 0).sort((a, b) => a.id.localeCompare(b.id, 'en')); ---- -{packages.map(({ id, facts }) => )} 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 b651933c64a..21638948991 100644 --- a/site/components/ObjectShell.astro +++ b/site/components/ObjectShell.astro @@ -1,7 +1,6 @@ --- import { DIAGNOSTICS_ENABLED } from "../diagnostics-policy.mts"; import ObjectInformationPanel from "./ObjectInformationPanel.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"; @@ -16,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'; @@ -231,33 +229,7 @@ import Detached from './Detached.astro'; }
- {!navigation && ' - + '
'; -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 704da6c0650..aa2020f5a72 100644 --- a/site/test/galactic-focus.test.mts +++ b/site/test/galactic-focus.test.mts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { catalogueObject, isHostedObject } from '@cssearth/objects'; +import { catalogueObject, isPlacedObject, isSceneObject } from '@cssearth/objects'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { searchObjects } from '../search/object-search.mts'; @@ -17,12 +17,13 @@ test('a globular cluster package is an entry of the registry from its own descri assert.ok(Math.abs(distance.value - 100) < 1e-9); const focus = catalogueObject({ descriptor, distance, discovery: { featured: false, imagery: true, illustration: false }, sceneHostId: 'sun' }, () => async () => { throw new Error('unused'); }); - assert.ok(isHostedObject(focus)); + assert.ok(isPlacedObject(focus) && !isSceneObject(focus), 'without a prepared scene the package is drawn by its host'); + assert.ok(focus.searchNames); assert.equal(focus.classification, 'globular-cluster'); assert.equal(focus.systemName, 'Milky Way'); assert.equal(focus.route, '/test-cluster/'); assert.equal(focus.sceneHostId, 'sun'); - const labels = [{ name: focus.name.toLowerCase(), names: focus.searchNames, classification: focus.classification, + 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); 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 4004a077cbd..ecec2343579 100644 --- a/site/test/navigation-ontology.test.mts +++ b/site/test/navigation-ontology.test.mts @@ -7,7 +7,7 @@ import { objectAdapter } from '../object-adapter.mts'; import { SEARCH_OBJECTS } from '../search/search-objects.mts'; import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { readObjectDescriptors, readOverviews } from '@cssearth/objects/node'; -import { distanceDescription, isExtendedClassification, isHostedDescriptor, isHostedObject, isOverviewObject, isPlacedObject, objectFacts, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { distanceDescription, isExtendedClassification, isHostedDescriptor, isOverviewObject, isPlacedObject, isSceneObject, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import { parsePreparedGalaxyCatalog, resolveSpatialCitation } from '@cssearth/catalog'; import { resolve } from 'node:path'; @@ -17,26 +17,25 @@ test('every scene and every package the host draws has exactly one searchable de // A hosted package's entry is its own descriptor's, as a scene's is: no catalogue row names a destination. const hosted = [...descriptors].filter(([, descriptor]) => isHostedDescriptor(descriptor) && isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.catalog !== undefined).map(([id]) => id); const overviews = await readOverviews(resolve('src/objects')); - assert.equal(OBJECTS.length, SCENE_OBJECTS.length + hosted.length + overviews.length); - assert.deepEqual(OBJECTS.filter(isHostedObject).map(object => object.id).sort(), hosted.sort()); + // A bank package is a scene like any body: its scene has no body in it, and the world draws its bank. + assert.equal(OBJECTS.length, SCENE_OBJECTS.length + overviews.length); + for (const id of hosted) assert.ok(isSceneObject(requireObject(id)), id); assert.deepEqual(OBJECTS.filter(isOverviewObject).map(object => object.id), overviews.map(overview => overview.id)); // One object can be more than one thing: the Local Group is a level and has a place in the world. assert.ok(isPlacedObject(requireObject('local-group')) && isOverviewObject(requireObject('local-group'))); // 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 => !isOverviewObject(object)).map(object => object.id))); assert.deepEqual(objectAdapter.routes(SCENE_OBJECTS), SCENE_OBJECTS.map(object => object.route)); - assert.ok(isHostedObject(requireObject('m31'))); - assert.throws(() => requireSceneObject('m31'), /no scene of its own/); + 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.ok(isHostedObject(object) && object.searchNames.some(name => name.includes(normalizeDestinationQuery(query!))), query); + assert.ok((object.searchNames ?? []).some(name => name.includes(normalizeDestinationQuery(query!))), query); } - // Each states its card's facts itself, every one with its source; no catalogue row is read for them. - for (const id of [...hosted, 'local-group']) assert.ok(objectFacts(descriptors.get(id)).some(fact => fact.id === 'distance'), id); // Each is a body of the world, placed by its own world frame. for (const id of hosted) { const object = requireObject(id); - assert.ok(isHostedObject(object) && isExtendedClassification(object.classification) && Math.hypot(...object.worldFrame.originM) > 0, id); + assert.ok(isPlacedObject(object) && isExtendedClassification(object.classification) && Math.hypot(...object.worldFrame.originM) > 0, id); } }); @@ -45,8 +44,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); 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 4f439943f1a..ce4e26b7764 100644 --- a/site/test/navigation-test-values.mts +++ b/site/test/navigation-test-values.mts @@ -22,8 +22,6 @@ 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 { diff --git a/site/test/nebula-search.test.mts b/site/test/nebula-search.test.mts index dc88325cc04..63bf08beaaf 100644 --- a/site/test/nebula-search.test.mts +++ b/site/test/nebula-search.test.mts @@ -2,13 +2,13 @@ import assert from 'node:assert/strict'; import { sourceTest } from '@cssearth/objects/node/source-test'; const test = sourceTest(); import { OBJECTS } from '../objects.mts'; -import { catalogueObject, defineObjects, isHostedObject, normalizeDestinationQuery } from '@cssearth/objects'; +import { catalogueObject, defineObjects, isSceneObject, normalizeDestinationQuery } from '@cssearth/objects'; test('rendered nebulae contribute common names and catalogue aliases to search', async () => { - const rows = OBJECTS.filter(isHostedObject).filter(object => object.classification === 'nebula'); + const rows = OBJECTS.filter(isSceneObject).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!)))); + 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}/`); } diff --git a/site/test/object-directory.test.mts b/site/test/object-directory.test.mts index 39411f732b9..ff5c3f6941c 100644 --- a/site/test/object-directory.test.mts +++ b/site/test/object-directory.test.mts @@ -3,6 +3,7 @@ import { sourceTest } from '@cssearth/objects/node/source-test'; const test = sourceTest(); import { OBJECTS, SCENE_OBJECTS as REGISTERED_SCENE_OBJECTS } from '../objects.mts'; import { readPreparedObjects } from '@cssearth/objects/node'; +import { isHostedDescriptor } from '@cssearth/objects'; import { resolve } from 'node:path'; import { OBJECT_ENTRY_IDS, objectEntry } from '../object-entry.mts'; import { knownObject, loadObject, objectFromEntry, NAVIGABLE_OBJECTS, SCENE_OBJECTS } from '../object-directory.mts'; @@ -23,7 +24,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)); - assert.deepEqual(prepared.sceneObjects.map(object => object.id), REGISTERED_SCENE_OBJECTS.map(object => object.id)); + // Preparation's scene objects are the bodies its lanes bake: a bank package's scene has no body to bake. + const banks = new Set(prepared.entries.flatMap(entry => isHostedDescriptor(entry.descriptor) ? [(entry.descriptor as { id: string }).id] : [])); + assert.deepEqual(prepared.sceneObjects.map(object => object.id), REGISTERED_SCENE_OBJECTS.filter(object => !banks.has(object.id)).map(object => object.id)); + assert.throws(() => prepared.requireSceneObject('m31'), /no body 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-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/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 813dc916d85..00000000000 --- a/site/test/prepared-context-navigation.test.mts +++ /dev/null @@ -1,494 +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 type { FocusObject, PreparedFocusPresentation } from '../prepared-focus.mts'; -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 = { object: FocusObject | null; presentation: PreparedFocusPresentation | null }; -/** `detailedObjectId` names the fixture bank the object's package holds; `presentation.focusRadiusM` the framing radius its world frame authors. */ -type FixtureOptions = { object?: { detailedObjectId?: string; presentation?: { focusRadiusM: number }; classification?: FocusObject['classification'] }; - 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); } }; - const bankKey = (id: string) => ['catalogue-a', 'catalogue-b'].includes(id) ? object.detailedObjectId ?? id : id; - // An object's world frame authors its framing radius: here the fixture's own, else what its bank frames. - const objectOf = (id: string): FocusObject | null => { - if (!['catalogue-a', 'catalogue-b'].includes(id)) return null; - const key = bankKey(id), volume = currentVolumeDatasetFrames[key]; - const bodyRadiusM = object.presentation?.focusRadiusM ?? (imageLayerFrames[key] ? 500 * imageLayerFrames[key].metersPerUnit - : volume ? (loadedFramingRadiusUnits ?? volume.framingRadiusUnits) * volume.frame.metersPerUnit : 1e18); - return { id, name: id, description: `The ${id}.`, aliases: [], classification: object.classification ?? 'galaxy', - worldFrame: { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [1e20, 0, 0], presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], metersPerUnit: bodyRadiusM, bodyRadiusM } }; - }; - const focusLayer: ContextLayer = { selectGalaxy: layer.selectGalaxy, - focusBank(objectId) { - const id = bankKey(objectId); - 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) }; - }, - }; - // 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); }, - objects: { known: objectOf, load: async id => objectOf(id) }, 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: ({ object, presentation, url: policy }) => { - available = true; content.push({ object, presentation }); - selection.focus(object, 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 object', () => { - const f = fixture({ unavailableObjectIds: ['catalogue-a'] }); - f.windowTarget.location.searchParams.set('dataset', 'eso-vista'); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.errors, []); - assert.equal(last(f.content).object?.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).object).id, 'catalogue-a'); - const flight = f.controller.select({ id: 'catalogue-b' }); - assert.equal(required(f.owner.preparedFocus()).id, 'catalogue-b'); - assert.equal(required(last(f.content).object).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).object).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 object the world's host draws/); - assert.equal(f.windowTarget.location.href, incoming); - assert.equal(f.selections.at(-1), null); - assert.equal(last(f.content).object, 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 is framed at its own authored radius', () => { - const f = fixture({ object: { classification: 'galaxy-cluster', presentation: { focusRadiusM: 9e22 } } }); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.errors, []); - assert.equal(required(f.owner.preparedFocus()).framingRadiusM, 9e22); - assert.equal(required(last(f.content).object).classification, '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).object?.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 7905f286057..00000000000 --- a/site/test/prepared-focus-card.test.mts +++ /dev/null @@ -1,175 +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 { FocusObject, 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']) { - 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()); - // The registry object the card presents: its id is its package's, so its banks are found by it. - const record: FocusObject = { id: 'prepared-galaxy', name: 'Prepared galaxy', description: 'A galaxy 50,000 parsecs away.', aliases: [], classification: 'galaxy', - worldFrame: { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [0, 0, 1e21], presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], metersPerUnit: 1e20, bodyRadiusM: 1e20 } }; - 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 its 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-introduction]')?.textContent, 'A galaxy 50,000 parsecs away.'); - f.card.set({ ...f.record, id: 'other-galaxy' }); - 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, id: '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 or globular-cluster focus keeps classification out of the title and preserves the shared dataset controls', () => { - for (const classification of ['nebula', 'globular-cluster'] as const) { - const f = fixture(); - f.card.set({ ...f.record, classification, aliases: ['Example 1'], description: 'A source-backed introduction.' }, f.presentation); - assert.equal(f.root.querySelector('[data-focus-introduction]')?.textContent, 'A source-backed introduction.'); - 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.bank.hidden, false); - assert.equal(f.datasetTab.hidden, false); - f.card.set(f.record, f.presentation); - assert.equal(f.root.querySelector('[data-focus-status]')?.hidden, false, 'A later galaxy restores its type tag'); - assert.equal(f.root.querySelector('[data-focus-status]')?.textContent, 'Galaxy'); - 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: 'other-galaxy' }); - 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(null); - assert.equal(link.hidden, true); - f.card.destroy(); -}); 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 = `<