From 4df27b2a61599b9621317149427cbe244afd8a0e Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Tue, 11 Aug 2026 22:40:39 -0700 Subject: [PATCH 1/7] Build initial interactive SolarSystem --- .dockerignore | 7 + .gitignore | 7 + AGENTS.md | 22 + Dockerfile | 13 + README.md | 52 +- compose.yaml | 13 + docs/ARCHITECTURE.md | 32 + docs/HANDOFF.md | 27 + docs/PROJECT.md | 26 + index.html | 14 + nginx.conf | 23 + package-lock.json | 2954 ++++++++++++++++++++++++++ package.json | 30 + src/App.tsx | 59 + src/data/bodies.ts | 114 + src/domain/celestial.ts | 63 + src/main.tsx | 10 + src/math/julian.ts | 12 + src/math/units.test.ts | 14 + src/math/units.ts | 15 + src/orbits/circular.ts | 30 + src/orbits/keplerian.ts | 86 + src/orbits/orbits.test.ts | 55 + src/orbits/provider.ts | 9 + src/orbits/system.ts | 41 + src/scene/SolarSystemScene.tsx | 239 +++ src/simulation/clock.test.ts | 16 + src/simulation/clock.ts | 19 + src/simulation/rotation.ts | 9 + src/simulation/useSimulationClock.ts | 36 + src/styles.css | 113 + src/ui/InfoPanel.tsx | 39 + src/ui/TimeControls.tsx | 45 + src/visualization/modes.ts | 30 + tsconfig.app.json | 22 + tsconfig.json | 7 + tsconfig.node.json | 16 + vite.config.ts | 10 + 38 files changed, 4328 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/HANDOFF.md create mode 100644 docs/PROJECT.md create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/App.tsx create mode 100644 src/data/bodies.ts create mode 100644 src/domain/celestial.ts create mode 100644 src/main.tsx create mode 100644 src/math/julian.ts create mode 100644 src/math/units.test.ts create mode 100644 src/math/units.ts create mode 100644 src/orbits/circular.ts create mode 100644 src/orbits/keplerian.ts create mode 100644 src/orbits/orbits.test.ts create mode 100644 src/orbits/provider.ts create mode 100644 src/orbits/system.ts create mode 100644 src/scene/SolarSystemScene.tsx create mode 100644 src/simulation/clock.test.ts create mode 100644 src/simulation/clock.ts create mode 100644 src/simulation/rotation.ts create mode 100644 src/simulation/useSimulationClock.ts create mode 100644 src/styles.css create mode 100644 src/ui/InfoPanel.tsx create mode 100644 src/ui/TimeControls.tsx create mode 100644 src/visualization/modes.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b9c2fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.github +coverage +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b42a7e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.DS_Store +*.local +.env +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2c10a02 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# SolarSystem agent guide + +SolarSystem is a client-only React, TypeScript, Vite, and React Three Fiber educational orbital explorer. + +## Durable architecture rules + +- Physical and simulated positions use AU in a J2000-style ecliptic frame. Rendering scale never belongs in astronomy data or orbit providers. +- The simulated UTC timestamp is the source of truth. Derive orbit and rotation state from it; do not accumulate per-frame body movement. +- Keep astronomical math deterministic, testable, and independent from React and Three.js. +- Address bodies by stable IDs and resolve parent/child orbits through the generic hierarchy. +- Keep authoritative/scientific data separate from visual metadata and document data sources. +- Treat the Keplerian model as educational approximation, not an authoritative ephemeris. + +## Commands + +- `npm run dev` — Vite development server +- `npm test` — unit test suite +- `npm run typecheck` — strict TypeScript check +- `npm run build` — production build +- `docker compose up --build` — production container at port 8080 + +Use explicit TypeScript types, avoid `any`, add math tests with new orbital behavior, and update `docs/HANDOFF.md` after substantial work. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..32e6c12 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.28-alpine AS production +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index 62e7f5a..8b2db26 100644 --- a/README.md +++ b/README.md @@ -1 +1,51 @@ -# SolarSystem \ No newline at end of file +# SolarSystem + +An interactive, browser-based 3D Solar System explorer. The initial milestone includes the Sun, eight planets, Earth's Moon, deterministic time controls, selection, camera focus, and three complementary views. + +The 3D view is an **educational Keplerian model**, not an authoritative ephemeris. See [project scope](docs/PROJECT.md) and [architecture](docs/ARCHITECTURE.md). + +## Local development + +Requires Node.js 24 or a current supported Node.js release. + +```bash +npm ci +npm run dev +``` + +Vite prints the local development URL. Run verification with: + +```bash +npm test +npm run typecheck +npm run build +npm run preview +``` + +## Docker + +The production image uses a Node build stage and serves only the compiled static app from nginx on HTTP port `8080`. + +```bash +docker build -t solarsystem:local . +docker run --name solarsystem -p 8080:8080 -d solarsystem:local +``` + +Open `http://localhost:8080`. Confirm health with `curl http://localhost:8080/healthz`, then remove the container with: + +```bash +docker stop solarsystem +docker rm solarsystem +``` + +Alternatively, `docker compose up --build -d` starts the same stateless service with its restart policy and health check; `docker compose down` stops it. + +### Unraid + +Install the image as a normal Docker container and map host port `8080` (or another available host port) to container TCP port `8080`. It needs no volume, database, privileged mode, host networking, or special capabilities. + +### Cloudflare Tunnel + +Keep the tunnel outside this repository. Point the public hostname's tunnel service at the container's local HTTP endpoint, such as `http://:8080`. TLS termination and credentials remain with Cloudflare infrastructure. + +The Dockerfile is compatible with future publication as `ghcr.io/kevinatlee/solarsystem`; registry automation is intentionally deferred. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5a050ea --- /dev/null +++ b/compose.yaml @@ -0,0 +1,13 @@ +services: + solarsystem: + build: . + image: solarsystem:local + ports: + - "8080:8080" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d114a49 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +## Modules + +- `src/data`: typed physical, informational, visual, and J2000 orbital data. +- `src/domain`: reusable celestial-body and vector contracts. +- `src/math`: canonical conversions and Julian-date utilities. +- `src/orbits`: interchangeable circular/Keplerian providers plus generic hierarchy composition. +- `src/simulation`: the central timestamp/rate clock and deterministic rotation calculation. +- `src/visualization`: mode definitions and AU-to-scene display transforms. +- `src/scene`: React Three Fiber rendering, orbit paths, labels, selection, and camera controls. +- `src/ui`: ordinary React control and information surfaces. + +## Simulation and coordinates + +The canonical state is a UTC timestamp in milliseconds. Each rendered update follows `timestamp → orbital provider → local position → hierarchy composition → visualization transform → Three.js`. Nothing increments a planet around its orbit, so pause, reversal, and arbitrary jumps do not accumulate drift. + +Astronomical distances and positions use AU; radii use km; orbital periods use days; rotation periods use hours; angles use radians. Planet vectors are heliocentric or parent-relative in an ecliptic/J2000-style frame. Scene axes map ecliptic `(x, y, z)` to Three.js `(x, z, y)`, making the ecliptic plane horizontal. + +## Orbital providers and hierarchy + +`OrbitalPositionProvider` accepts a typed body and timestamp and returns a parent-relative AU vector. The circular provider is deliberately illustrative. The Keplerian provider solves Kepler's equation, derives orbital-plane coordinates, and applies node/inclination/periapsis rotations. `calculateSystemPositions` composes any parent chain by stable ID; Earth/Moon is the first proof of this generic mechanism. A future Horizons, DE, or SPICE adapter can implement the same provider boundary. + +## Visualization and scale + +Modes choose the provider and layout intent. `toScenePosition` is the boundary where physical AU positions become view coordinates. Distance scale and body radius scale are independent: true-distance ratios can coexist with visible exaggerated bodies. The lineup bypasses orbital positions entirely and performs a display-only size layout. + +## Data sources and limits + +Planetary orbital elements and rates use JPL Solar System Dynamics, [Approximate Positions of the Planets](https://ssd.jpl.nasa.gov/planets/approx_pos.html), Table 1 (1800–2050). Physical radii, rotation periods, and tilts are rounded from NASA/NSSDC planetary fact sheets. The Moon uses standard educational mean elements. Two-body elements omit perturbations and should never be presented as observation-grade coordinates. + +The visual layer updates only this small initial body set through React today. If catalogs reach hundreds or thousands of objects, migrate high-frequency body matrices to instancing and mutable render-loop buffers without changing the provider/domain boundaries. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..16a08fc --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,27 @@ +# Handoff + +## State + +- Branch: `codex/initial-architecture` +- Commit/PR: pending commit and PR creation +- Initial vertical slice implemented: typed body catalog, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. + +## Verification + +- `npm test`: 12/12 passing +- `npm run typecheck`: passing +- `npm run build`: passing (expected Three.js bundle-size warning) +- Browser smoke test: passing for body render, mode switch, selection, details, pause, and reverse speed; no runtime errors +- Production preview: root and direct `/explore/earth` navigation both returned HTTP 200 +- Docker: execution not available because the host has no `docker` command; Dockerfile, Compose, nginx health endpoint, and SPA fallback were inspected but not executed + +## Decisions and limitations + +- AU/J2000-style ecliptic simulation data stays separate from scene scaling. +- JPL approximate elements are educational, not ephemeris-grade; Moon elements are mean approximations. +- Body radii are exaggerated outside lineup; lineup explicitly applies nonlinear radius compression. Moon orbital display radius is enlarged. +- Camera focus eases to a body; persistent follow mode and high-volume instancing are future work. + +## Recommended next task + +Architecture/code review and hands-on interaction testing, followed by targeted fixes before squash merge. A subsequent visual milestone can add better procedural materials and camera-follow behavior without changing the simulation layer. diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..09143db --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,26 @@ +# Product overview + +SolarSystem makes the scale and motion of our planetary system approachable through multiple explicit visual interpretations. Its simulation truth remains independent from how a view transforms distance and body size. + +## Current scope + +- Sun, eight planets, and Earth's Moon +- current-time initialization and arbitrary UTC date/time selection +- play, pause, forward, reverse, speed presets, and reset to now +- selectable bodies, educational facts, coordinates, and camera focus +- simplified circular overview with compressed distances +- body-size lineup with intentionally compressed Sun scale +- 3D educational Keplerian view with ellipse shape and orbital orientation +- Docker/nginx self-hosting + +## Visualization terminology + +- **Simplified Solar System:** circular, coplanar, visibly enlarged bodies; approximate phase positions. +- **Planet lineup:** side-by-side size comparison; orbital distance is irrelevant and radii use an explicitly nonlinear compression to keep the full range readable. +- **Educational Keplerian model:** deterministic two-body approximation using J2000-style orbital elements. It is not a JPL Horizons/DE/SPICE ephemeris. +- **True distance:** preserves orbital-distance ratios while still exaggerating body radii. +- **Compressed distance:** applies a display-only nonlinear transform so outer planets remain explorable. + +## Future scope + +Additional moons and minor-body categories, category filtering, camera following, improved body materials, runtime configuration when actually needed, and a replaceable high-accuracy ephemeris provider. Accounts, a backend, n-body physics, SPICE integration, and large catalogs are non-goals for this milestone. diff --git a/index.html b/index.html new file mode 100644 index 0000000..38cd557 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + SolarSystem — interactive orbital explorer + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..b80324a --- /dev/null +++ b/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 8080; + listen [::]:8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 'healthy\n'; + } + + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..875c100 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2954 @@ +{ + "name": "solar-system", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "solar-system", + "version": "0.1.0", + "dependencies": { + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.4.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "three": "^0.183.1" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^5.1.4", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.8", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.8.tgz", + "integrity": "sha512-rJXyuzLm2Xq0kafHuR47ajDGbOe/pEhzIr4m8E8zwzQs0iNjloFDqBwRhrXmP/w+onLeYyN3EYPFW/cwWK/4yA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz", + "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/hls.js": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.18.tgz", + "integrity": "sha512-Rtovq9oJt9HWBqFJF9wZrZM7dRvdGHYNdMMv+wSDGDE2+FIybgs8RPJpR3kya1TZKBHVv2NOgLmn/M0MFjrQ3g==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz", + "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.5", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz", + "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d43d0df --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "solar-system", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "test:watch": "vitest", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.4.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "three": "^0.183.1" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^5.1.4", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..703cd92 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react' +import { CELESTIAL_BODIES } from './data/bodies' +import { circularProvider } from './orbits/circular' +import { keplerianProvider } from './orbits/keplerian' +import { calculateSystemPositions } from './orbits/system' +import { SolarSystemScene } from './scene/SolarSystemScene' +import { useSimulationClock } from './simulation/useSimulationClock' +import { InfoPanel } from './ui/InfoPanel' +import { TimeControls } from './ui/TimeControls' +import { VISUALIZATION_MODES, type DistanceScaleId, type VisualizationModeId } from './visualization/modes' + +export const App = () => { + const clock = useSimulationClock() + const [mode, setMode] = useState('simplified') + const [distanceScale, setDistanceScale] = useState('compressed') + const [selectedId, setSelectedId] = useState('earth') + const [focusRequest, setFocusRequest] = useState(0) + const [showOrbits, setShowOrbits] = useState(true) + const activeMode = VISUALIZATION_MODES.find((item) => item.id === mode)! + const positions = useMemo( + () => calculateSystemPositions(CELESTIAL_BODIES, clock.timestampMs, mode === 'keplerian' ? keplerianProvider : circularProvider), + [clock.timestampMs, mode], + ) + const selected = selectedId ? positions.get(selectedId) : undefined + + return ( +
+
+
SolarSystemOrbital explorer
+ +
+ {mode !== 'lineup' && } + +
+
+
+ +
+ Active model + {activeMode.label} + {activeMode.description} +
+
Drag to orbit · Scroll to zoom · Click a body
+ + {selected && setSelectedId(null)} onFocus={() => setFocusRequest((value) => value + 1)} />} +
+
Educational model · Approximate positions, not an authoritative ephemeris
+
+ ) +} diff --git a/src/data/bodies.ts b/src/data/bodies.ts new file mode 100644 index 0000000..e47a5ff --- /dev/null +++ b/src/data/bodies.ts @@ -0,0 +1,114 @@ +import type { CelestialBody, OrbitalElements } from '../domain/celestial' +import { J2000_JULIAN_DATE } from '../math/julian' +import { degreesToRadians, kmToAu } from '../math/units' + +// Planet elements and rates are derived from JPL Solar System Dynamics' table +// for approximate positions (valid 1800–2050). Mean anomaly is L - longitude +// of perihelion and argument of periapsis is longitude of perihelion - node. +// https://ssd.jpl.nasa.gov/planets/approx_pos.html +const jplOrbit = ( + semiMajorAxisAu: number, + eccentricity: number, + inclinationDeg: number, + meanLongitudeDeg: number, + longitudePerihelionDeg: number, + longitudeNodeDeg: number, + orbitalPeriodDays: number, + rates: readonly [number, number, number, number, number, number], +): OrbitalElements => ({ + semiMajorAxisAu, + eccentricity, + inclinationRad: degreesToRadians(inclinationDeg), + longitudeAscendingNodeRad: degreesToRadians(longitudeNodeDeg), + argumentPeriapsisRad: degreesToRadians(longitudePerihelionDeg - longitudeNodeDeg), + meanAnomalyAtEpochRad: degreesToRadians(meanLongitudeDeg - longitudePerihelionDeg), + epochJulianDate: J2000_JULIAN_DATE, + orbitalPeriodDays, + ratesPerJulianCentury: { + semiMajorAxisAu: rates[0], + eccentricity: rates[1], + inclinationRad: degreesToRadians(rates[2]), + meanAnomalyRad: degreesToRadians(rates[3] - rates[4]), + argumentPeriapsisRad: degreesToRadians(rates[4] - rates[5]), + longitudeAscendingNodeRad: degreesToRadians(rates[5]), + }, +}) + +export const CELESTIAL_BODIES: readonly CelestialBody[] = [ + { + id: 'sun', name: 'Sun', category: 'star', radiusKm: 695_700, massKg: 1.9885e30, + siderealRotationPeriodHours: 609.12, axialTiltRad: degreesToRadians(7.25), + visual: { color: '#ffc85c', accent: '#fff1ad', emissive: '#ff7a18' }, + description: 'The star at the center of our planetary system, containing more than 99.8% of its mass.', + }, + { + id: 'mercury', name: 'Mercury', category: 'planet', parentId: 'sun', radiusKm: 2_439.7, + massKg: 3.3011e23, siderealRotationPeriodHours: 1_407.6, axialTiltRad: degreesToRadians(0.034), + orbit: jplOrbit(0.38709927, 0.20563593, 7.00497902, 252.2503235, 77.45779628, 48.33076593, 87.9691, [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081]), + simplifiedOrbitRadius: 2.7, visual: { color: '#9c948c', accent: '#d9d0c5' }, + description: 'The smallest planet and the fastest to orbit the Sun, with a heavily cratered surface.', + }, + { + id: 'venus', name: 'Venus', category: 'planet', parentId: 'sun', radiusKm: 6_051.8, + massKg: 4.8675e24, siderealRotationPeriodHours: -5_832.5, axialTiltRad: degreesToRadians(177.36), + orbit: jplOrbit(0.72333566, 0.00677672, 3.39467605, 181.9790995, 131.60246718, 76.67984255, 224.701, [0.0000039, -0.00004107, -0.0007889, 58517.81538729, 0.00268329, -0.27769418]), + simplifiedOrbitRadius: 4.0, visual: { color: '#d9a66c', accent: '#ffe0a8' }, + description: 'A cloud-covered world with a dense carbon-dioxide atmosphere and retrograde rotation.', + }, + { + id: 'earth', name: 'Earth', category: 'planet', parentId: 'sun', radiusKm: 6_371, + massKg: 5.9722e24, siderealRotationPeriodHours: 23.9345, axialTiltRad: degreesToRadians(23.44), + orbit: jplOrbit(1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0, 365.256, [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0]), + simplifiedOrbitRadius: 5.5, visual: { color: '#2678ff', accent: '#71d9ff' }, + description: 'Our ocean world—the only place currently known to support life.', + }, + { + id: 'moon', name: 'Moon', category: 'moon', parentId: 'earth', radiusKm: 1_737.4, + massKg: 7.342e22, siderealRotationPeriodHours: 655.728, axialTiltRad: degreesToRadians(6.68), + orbit: { + semiMajorAxisAu: kmToAu(384_400), eccentricity: 0.0549, + inclinationRad: degreesToRadians(5.145), longitudeAscendingNodeRad: degreesToRadians(125.08), + argumentPeriapsisRad: degreesToRadians(318.15), meanAnomalyAtEpochRad: degreesToRadians(115.3654), + epochJulianDate: J2000_JULIAN_DATE, orbitalPeriodDays: 27.321661, + }, + simplifiedOrbitRadius: 0.48, visual: { color: '#b7bbc3', accent: '#eef1f5' }, + description: 'Earth’s natural satellite. Its rotation period matches its orbit, keeping one face toward Earth.', + }, + { + id: 'mars', name: 'Mars', category: 'planet', parentId: 'sun', radiusKm: 3_389.5, + massKg: 6.4171e23, siderealRotationPeriodHours: 24.6229, axialTiltRad: degreesToRadians(25.19), + orbit: jplOrbit(1.52371034, 0.0933941, 1.84969142, -4.55343205, -23.94362959, 49.55953891, 686.98, [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343]), + simplifiedOrbitRadius: 7.0, visual: { color: '#c34f32', accent: '#ff9971' }, + description: 'A cold desert world shaped by volcanoes, impact basins, and ancient flowing water.', + }, + { + id: 'jupiter', name: 'Jupiter', category: 'planet', parentId: 'sun', radiusKm: 69_911, + massKg: 1.8982e27, siderealRotationPeriodHours: 9.925, axialTiltRad: degreesToRadians(3.13), + orbit: jplOrbit(5.202887, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909, 4_332.589, [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106]), + simplifiedOrbitRadius: 9.25, visual: { color: '#d3aa7d', accent: '#f5d5a6' }, + description: 'The largest planet, a rapidly rotating gas giant with a vast system of moons.', + }, + { + id: 'saturn', name: 'Saturn', category: 'planet', parentId: 'sun', radiusKm: 58_232, + massKg: 5.6834e26, siderealRotationPeriodHours: 10.656, axialTiltRad: degreesToRadians(26.73), + orbit: jplOrbit(9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448, 10_759.22, [-0.0012506, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794]), + simplifiedOrbitRadius: 11.5, visual: { color: '#d9bd76', accent: '#ffebae', hasRings: true }, + description: 'A pale gas giant encircled by an intricate, icy ring system.', + }, + { + id: 'uranus', name: 'Uranus', category: 'planet', parentId: 'sun', radiusKm: 25_362, + massKg: 8.681e25, siderealRotationPeriodHours: -17.24, axialTiltRad: degreesToRadians(97.77), + orbit: jplOrbit(19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.9542763, 74.01692503, 30_688.5, [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589]), + simplifiedOrbitRadius: 13.7, visual: { color: '#6ed4df', accent: '#bdf6ff' }, + description: 'An ice giant rotating on its side, likely after a dramatic collision early in its history.', + }, + { + id: 'neptune', name: 'Neptune', category: 'planet', parentId: 'sun', radiusKm: 24_622, + massKg: 1.02413e26, siderealRotationPeriodHours: 16.11, axialTiltRad: degreesToRadians(28.32), + orbit: jplOrbit(30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574, 60_182, [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664]), + simplifiedOrbitRadius: 16.0, visual: { color: '#3159cf', accent: '#719aff' }, + description: 'The outermost major planet, an ice giant with supersonic winds and dark storms.', + }, +] as const + +export const bodyById = new Map(CELESTIAL_BODIES.map((body) => [body.id, body])) diff --git a/src/domain/celestial.ts b/src/domain/celestial.ts new file mode 100644 index 0000000..e4a3fed --- /dev/null +++ b/src/domain/celestial.ts @@ -0,0 +1,63 @@ +export type BodyCategory = + | 'star' + | 'planet' + | 'moon' + | 'dwarf-planet' + | 'asteroid' + | 'comet' + | 'artificial-satellite' + +export interface Vector3Au { + x: number + y: number + z: number +} + +export interface ElementRates { + semiMajorAxisAu?: number + eccentricity?: number + inclinationRad?: number + longitudeAscendingNodeRad?: number + argumentPeriapsisRad?: number + meanAnomalyRad?: number +} + +export interface OrbitalElements { + semiMajorAxisAu: number + eccentricity: number + inclinationRad: number + longitudeAscendingNodeRad: number + argumentPeriapsisRad: number + meanAnomalyAtEpochRad: number + epochJulianDate: number + orbitalPeriodDays: number + ratesPerJulianCentury?: ElementRates +} + +export interface VisualMetadata { + color: string + accent: string + emissive?: string + hasRings?: boolean +} + +export interface CelestialBody { + id: string + name: string + category: BodyCategory + parentId?: string + radiusKm: number + massKg?: number + siderealRotationPeriodHours?: number + axialTiltRad?: number + orbit?: OrbitalElements + simplifiedOrbitRadius?: number + visual: VisualMetadata + description: string +} + +export interface PositionedBody { + body: CelestialBody + positionAu: Vector3Au + localPositionAu: Vector3Au +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8661d46 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/math/julian.ts b/src/math/julian.ts new file mode 100644 index 0000000..1bfec7b --- /dev/null +++ b/src/math/julian.ts @@ -0,0 +1,12 @@ +export const J2000_JULIAN_DATE = 2_451_545 +export const UNIX_EPOCH_JULIAN_DATE = 2_440_587.5 +export const MILLISECONDS_PER_DAY = 86_400_000 + +export const dateToJulianDate = (date: Date): number => + UNIX_EPOCH_JULIAN_DATE + date.getTime() / MILLISECONDS_PER_DAY + +export const timestampToJulianDate = (timestampMs: number): number => + UNIX_EPOCH_JULIAN_DATE + timestampMs / MILLISECONDS_PER_DAY + +export const julianDateToTimestamp = (julianDate: number): number => + (julianDate - UNIX_EPOCH_JULIAN_DATE) * MILLISECONDS_PER_DAY diff --git a/src/math/units.test.ts b/src/math/units.test.ts new file mode 100644 index 0000000..c1d4fe1 --- /dev/null +++ b/src/math/units.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { ASTRONOMICAL_UNIT_KM, auToKm, degreesToRadians, kmToAu, normalizeRadians } from './units' + +describe('unit conversions', () => { + it('converts astronomical units and kilometres', () => { + expect(auToKm(1)).toBe(ASTRONOMICAL_UNIT_KM) + expect(kmToAu(ASTRONOMICAL_UNIT_KM)).toBe(1) + }) + + it('converts and normalizes angles', () => { + expect(degreesToRadians(180)).toBeCloseTo(Math.PI) + expect(normalizeRadians(-Math.PI / 2)).toBeCloseTo(Math.PI * 1.5) + }) +}) diff --git a/src/math/units.ts b/src/math/units.ts new file mode 100644 index 0000000..3f5bcb2 --- /dev/null +++ b/src/math/units.ts @@ -0,0 +1,15 @@ +export const ASTRONOMICAL_UNIT_KM = 149_597_870.7 +export const SECONDS_PER_DAY = 86_400 +export const HOURS_PER_DAY = 24 +export const DAYS_PER_JULIAN_CENTURY = 36_525 + +export const degreesToRadians = (degrees: number): number => (degrees * Math.PI) / 180 +export const radiansToDegrees = (radians: number): number => (radians * 180) / Math.PI +export const auToKm = (au: number): number => au * ASTRONOMICAL_UNIT_KM +export const kmToAu = (km: number): number => km / ASTRONOMICAL_UNIT_KM +export const daysToMilliseconds = (days: number): number => days * SECONDS_PER_DAY * 1000 + +export const normalizeRadians = (angle: number): number => { + const tau = Math.PI * 2 + return ((angle % tau) + tau) % tau +} diff --git a/src/orbits/circular.ts b/src/orbits/circular.ts new file mode 100644 index 0000000..bb77e3a --- /dev/null +++ b/src/orbits/circular.ts @@ -0,0 +1,30 @@ +import type { CelestialBody, Vector3Au } from '../domain/celestial' +import { timestampToJulianDate } from '../math/julian' +import { normalizeRadians } from '../math/units' +import type { OrbitalPositionProvider } from './provider' +import { ZERO_VECTOR } from './provider' + +export const circularPosition = ( + radius: number, + periodDays: number, + elapsedDays: number, + phaseRad = 0, +): Vector3Au => { + const angle = normalizeRadians(phaseRad + (elapsedDays / periodDays) * Math.PI * 2) + return { x: radius * Math.cos(angle), y: radius * Math.sin(angle), z: 0 } +} + +export const circularProvider: OrbitalPositionProvider = { + id: 'circular', + label: 'Simplified circular model', + positionAt(body: CelestialBody, timestampMs: number): Vector3Au { + if (!body.orbit) return ZERO_VECTOR + const elapsedDays = timestampToJulianDate(timestampMs) - body.orbit.epochJulianDate + return circularPosition( + body.orbit.semiMajorAxisAu, + body.orbit.orbitalPeriodDays, + elapsedDays, + body.orbit.meanAnomalyAtEpochRad, + ) + }, +} diff --git a/src/orbits/keplerian.ts b/src/orbits/keplerian.ts new file mode 100644 index 0000000..c917d34 --- /dev/null +++ b/src/orbits/keplerian.ts @@ -0,0 +1,86 @@ +import type { CelestialBody, OrbitalElements, Vector3Au } from '../domain/celestial' +import { timestampToJulianDate } from '../math/julian' +import { DAYS_PER_JULIAN_CENTURY, normalizeRadians } from '../math/units' +import type { OrbitalPositionProvider } from './provider' +import { ZERO_VECTOR } from './provider' + +export const solveEccentricAnomaly = ( + meanAnomalyRad: number, + eccentricity: number, + tolerance = 1e-12, + maxIterations = 30, +): number => { + if (eccentricity < 0 || eccentricity >= 1) { + throw new RangeError('Educational Kepler solver requires 0 <= eccentricity < 1') + } + + const mean = normalizeRadians(meanAnomalyRad) + let eccentric = eccentricity < 0.8 ? mean : Math.PI + for (let iteration = 0; iteration < maxIterations; iteration += 1) { + const delta = + (eccentric - eccentricity * Math.sin(eccentric) - mean) / + (1 - eccentricity * Math.cos(eccentric)) + eccentric -= delta + if (Math.abs(delta) <= tolerance) return eccentric + } + throw new Error('Kepler equation did not converge') +} + +const elementsAt = (elements: OrbitalElements, julianDate: number): OrbitalElements => { + const centuries = (julianDate - elements.epochJulianDate) / DAYS_PER_JULIAN_CENTURY + const rates = elements.ratesPerJulianCentury + if (!rates) return elements + return { + ...elements, + semiMajorAxisAu: elements.semiMajorAxisAu + (rates.semiMajorAxisAu ?? 0) * centuries, + eccentricity: elements.eccentricity + (rates.eccentricity ?? 0) * centuries, + inclinationRad: elements.inclinationRad + (rates.inclinationRad ?? 0) * centuries, + longitudeAscendingNodeRad: + elements.longitudeAscendingNodeRad + (rates.longitudeAscendingNodeRad ?? 0) * centuries, + argumentPeriapsisRad: + elements.argumentPeriapsisRad + (rates.argumentPeriapsisRad ?? 0) * centuries, + } +} + +export const keplerianPositionAtJulianDate = ( + sourceElements: OrbitalElements, + julianDate: number, +): Vector3Au => { + const elements = elementsAt(sourceElements, julianDate) + const elapsedDays = julianDate - elements.epochJulianDate + const meanMotion = (Math.PI * 2) / elements.orbitalPeriodDays + const meanAnomaly = elements.meanAnomalyAtEpochRad + meanMotion * elapsedDays + const eccentricAnomaly = solveEccentricAnomaly(meanAnomaly, elements.eccentricity) + + const xOrbital = elements.semiMajorAxisAu * (Math.cos(eccentricAnomaly) - elements.eccentricity) + const yOrbital = + elements.semiMajorAxisAu * + Math.sqrt(1 - elements.eccentricity ** 2) * + Math.sin(eccentricAnomaly) + + const cosOmega = Math.cos(elements.longitudeAscendingNodeRad) + const sinOmega = Math.sin(elements.longitudeAscendingNodeRad) + const cosInclination = Math.cos(elements.inclinationRad) + const sinInclination = Math.sin(elements.inclinationRad) + const cosPeriapsis = Math.cos(elements.argumentPeriapsisRad) + const sinPeriapsis = Math.sin(elements.argumentPeriapsisRad) + + return { + x: + (cosPeriapsis * cosOmega - sinPeriapsis * sinOmega * cosInclination) * xOrbital + + (-sinPeriapsis * cosOmega - cosPeriapsis * sinOmega * cosInclination) * yOrbital, + y: + (cosPeriapsis * sinOmega + sinPeriapsis * cosOmega * cosInclination) * xOrbital + + (-sinPeriapsis * sinOmega + cosPeriapsis * cosOmega * cosInclination) * yOrbital, + z: sinPeriapsis * sinInclination * xOrbital + cosPeriapsis * sinInclination * yOrbital, + } +} + +export const keplerianProvider: OrbitalPositionProvider = { + id: 'keplerian', + label: 'Educational Keplerian model', + positionAt(body: CelestialBody, timestampMs: number): Vector3Au { + if (!body.orbit) return ZERO_VECTOR + return keplerianPositionAtJulianDate(body.orbit, timestampToJulianDate(timestampMs)) + }, +} diff --git a/src/orbits/orbits.test.ts b/src/orbits/orbits.test.ts new file mode 100644 index 0000000..41da12e --- /dev/null +++ b/src/orbits/orbits.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { CelestialBody, OrbitalElements } from '../domain/celestial' +import { J2000_JULIAN_DATE } from '../math/julian' +import { circularPosition } from './circular' +import { keplerianPositionAtJulianDate, solveEccentricAnomaly } from './keplerian' +import { calculateSystemPositions } from './system' + +const circularElements: OrbitalElements = { + semiMajorAxisAu: 2, eccentricity: 0, inclinationRad: 0, + longitudeAscendingNodeRad: 0, argumentPeriapsisRad: 0, meanAnomalyAtEpochRad: 0, + epochJulianDate: J2000_JULIAN_DATE, orbitalPeriodDays: 10, +} + +describe('orbital math', () => { + it('solves a circular Kepler equation exactly', () => { + expect(solveEccentricAnomaly(1.25, 0)).toBeCloseTo(1.25, 12) + }) + + it('solves an eccentric Kepler equation', () => { + const eccentric = solveEccentricAnomaly(1.1, 0.3) + expect(eccentric - 0.3 * Math.sin(eccentric)).toBeCloseTo(1.1, 10) + }) + + it('returns a known circular-orbit position', () => { + expect(keplerianPositionAtJulianDate(circularElements, J2000_JULIAN_DATE)).toEqual({ x: 2, y: 0, z: 0 }) + }) + + it('repeats after one orbital period', () => { + const start = keplerianPositionAtJulianDate(circularElements, J2000_JULIAN_DATE) + const repeated = keplerianPositionAtJulianDate(circularElements, J2000_JULIAN_DATE + 10) + expect(repeated.x).toBeCloseTo(start.x, 10) + expect(repeated.y).toBeCloseTo(start.y, 10) + }) + + it('applies orbital-plane inclination', () => { + const inclined = { ...circularElements, inclinationRad: Math.PI / 2 } + const position = keplerianPositionAtJulianDate(inclined, J2000_JULIAN_DATE + 2.5) + expect(position.y).toBeCloseTo(0, 10) + expect(position.z).toBeCloseTo(2, 10) + }) + + it('calculates circular motion and period repetition', () => { + expect(circularPosition(3, 12, 3).x).toBeCloseTo(0, 10) + expect(circularPosition(3, 12, 12).x).toBeCloseTo(3, 10) + }) + + it('composes parent and child positions through stable IDs', () => { + const bodies: CelestialBody[] = [ + { id: 'parent', name: 'Parent', category: 'planet', radiusKm: 1, orbit: circularElements, visual: { color: '#fff', accent: '#fff' }, description: '' }, + { id: 'child', name: 'Child', category: 'moon', parentId: 'parent', radiusKm: 1, orbit: { ...circularElements, semiMajorAxisAu: 0.1 }, visual: { color: '#fff', accent: '#fff' }, description: '' }, + ] + const provider = { id: 'fixed', label: 'fixed', positionAt: (body: CelestialBody) => ({ x: body.id === 'parent' ? 2 : 0.1, y: 0, z: 0 }) } + expect(calculateSystemPositions(bodies, 0, provider).get('child')?.positionAu.x).toBeCloseTo(2.1) + }) +}) diff --git a/src/orbits/provider.ts b/src/orbits/provider.ts new file mode 100644 index 0000000..a8d572e --- /dev/null +++ b/src/orbits/provider.ts @@ -0,0 +1,9 @@ +import type { CelestialBody, Vector3Au } from '../domain/celestial' + +export interface OrbitalPositionProvider { + readonly id: string + readonly label: string + positionAt(body: CelestialBody, timestampMs: number): Vector3Au +} + +export const ZERO_VECTOR: Vector3Au = { x: 0, y: 0, z: 0 } diff --git a/src/orbits/system.ts b/src/orbits/system.ts new file mode 100644 index 0000000..d11ee84 --- /dev/null +++ b/src/orbits/system.ts @@ -0,0 +1,41 @@ +import type { CelestialBody, PositionedBody, Vector3Au } from '../domain/celestial' +import type { OrbitalPositionProvider } from './provider' + +const addVectors = (left: Vector3Au, right: Vector3Au): Vector3Au => ({ + x: left.x + right.x, + y: left.y + right.y, + z: left.z + right.z, +}) + +export const calculateSystemPositions = ( + bodies: readonly CelestialBody[], + timestampMs: number, + provider: OrbitalPositionProvider, +): Map => { + const bodyById = new Map(bodies.map((body) => [body.id, body])) + const result = new Map() + const visiting = new Set() + + const positionBody = (body: CelestialBody): PositionedBody => { + const existing = result.get(body.id) + if (existing) return existing + if (visiting.has(body.id)) throw new Error(`Orbit hierarchy contains a cycle at ${body.id}`) + visiting.add(body.id) + + const localPositionAu = provider.positionAt(body, timestampMs) + let positionAu = localPositionAu + if (body.parentId) { + const parent = bodyById.get(body.parentId) + if (!parent) throw new Error(`Unknown parent ${body.parentId} for ${body.id}`) + positionAu = addVectors(positionBody(parent).positionAu, localPositionAu) + } + + const positioned = { body, positionAu, localPositionAu } + result.set(body.id, positioned) + visiting.delete(body.id) + return positioned + } + + bodies.forEach(positionBody) + return result +} diff --git a/src/scene/SolarSystemScene.tsx b/src/scene/SolarSystemScene.tsx new file mode 100644 index 0000000..aff5d8b --- /dev/null +++ b/src/scene/SolarSystemScene.tsx @@ -0,0 +1,239 @@ +import { Html, Line, OrbitControls, Stars } from '@react-three/drei' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' +import type { Group, Mesh } from 'three' +import { Vector3 } from 'three' +import { CELESTIAL_BODIES, bodyById } from '../data/bodies' +import type { CelestialBody, PositionedBody, Vector3Au } from '../domain/celestial' +import { circularProvider } from '../orbits/circular' +import { keplerianProvider } from '../orbits/keplerian' +import { calculateSystemPositions } from '../orbits/system' +import { rotationAngleAt } from '../simulation/rotation' +import { + toScenePosition, + type DistanceScaleId, + type VisualizationModeId, +} from '../visualization/modes' + +interface SolarSystemSceneProps { + timestampMs: number + mode: VisualizationModeId + distanceScale: DistanceScaleId + selectedId: string | null + focusRequest: number + showOrbits: boolean + onSelect: (id: string) => void +} + +const planetIds = CELESTIAL_BODIES.filter((body) => body.category !== 'moon').map((body) => body.id) + +const sceneRadius = (body: CelestialBody, mode: VisualizationModeId): number => { + if (mode === 'lineup') { + if (body.id === 'sun') return 3.6 + return Math.max(0.22, Math.sqrt(body.radiusKm / 6_371) * 0.54) + } + if (body.id === 'sun') return 0.72 + if (body.category === 'moon') return 0.105 + return Math.max(0.13, Math.pow(body.radiusKm / 6_371, 0.38) * 0.2) +} + +const lineupPositions = (): Map => { + const positions = new Map() + let cursor = -14 + planetIds.forEach((id) => { + const body = bodyById.get(id)! + const radius = sceneRadius(body, 'lineup') + cursor += radius + positions.set(id, [cursor, 0, 0]) + cursor += radius + (id === 'sun' ? 2.2 : 0.8) + }) + positions.set('moon', [15.6, -0.25, 0]) + return positions +} + +const toDisplayPositions = ( + positioned: Map, + mode: VisualizationModeId, + scale: DistanceScaleId, +): Map => { + if (mode === 'lineup') return lineupPositions() + const display = new Map() + positioned.forEach((entry) => { + if (entry.body.category === 'moon') return + if (mode === 'simplified' && entry.body.orbit) { + const physicalRadius = Math.hypot(entry.positionAu.x, entry.positionAu.y, entry.positionAu.z) || 1 + const displayRadius = entry.body.simplifiedOrbitRadius ?? physicalRadius + display.set(entry.body.id, [ + entry.positionAu.x / physicalRadius * displayRadius, + entry.positionAu.z / physicalRadius * displayRadius, + entry.positionAu.y / physicalRadius * displayRadius, + ]) + } else { + display.set(entry.body.id, toScenePosition(entry.positionAu, mode, scale)) + } + }) + const moon = positioned.get('moon') + const earth = display.get('earth') + if (moon && earth) { + const local = moon.localPositionAu + const length = Math.hypot(local.x, local.y, local.z) || 1 + const exaggerated = mode === 'simplified' ? 1 : 0.5 + display.set('moon', [ + earth[0] + (local.x / length) * exaggerated, + earth[1] + (local.z / length) * exaggerated, + earth[2] + (local.y / length) * exaggerated, + ]) + } + return display +} + +interface BodyMeshProps { + body: CelestialBody + position: [number, number, number] + timestampMs: number + mode: VisualizationModeId + selected: boolean + onSelect: (id: string) => void +} + +const BodyMesh = ({ body, position, timestampMs, mode, selected, onSelect }: BodyMeshProps) => { + const mesh = useRef(null) + const tilt = body.axialTiltRad ?? 0 + useEffect(() => { + if (mesh.current) mesh.current.rotation.y = rotationAngleAt(body, timestampMs) + }, [body, timestampMs]) + const radius = sceneRadius(body, mode) + + return ( + + { event.stopPropagation(); onSelect(body.id) }} + onPointerOver={() => { document.body.style.cursor = 'pointer' }} + onPointerOut={() => { document.body.style.cursor = 'default' }} + > + + + + {body.visual.hasRings && ( + + + + + )} + {selected && ( + + + + + )} + + + + + ) +} + +const CameraController = ({ selectedId, focusRequest, displayPositions, mode }: { + selectedId: string | null + focusRequest: number + displayPositions: Map + mode: VisualizationModeId +}) => { + const controls = useRef>(null) + const { camera } = useThree() + const target = useRef(new Vector3()) + const desiredCamera = useRef(new Vector3()) + + useEffect(() => { + const point = selectedId ? displayPositions.get(selectedId) : undefined + const nextTarget = point ? new Vector3(...point) : new Vector3(0, 0, 0) + const distance = selectedId ? (mode === 'lineup' ? 5 : 4) : (mode === 'lineup' ? 31 : 23) + target.current.copy(nextTarget) + desiredCamera.current.set(nextTarget.x + distance * 0.48, nextTarget.y + distance * 0.48, nextTarget.z + distance) + }, [camera, displayPositions, focusRequest, mode, selectedId]) + + useFrame(() => { + if (!controls.current) return + controls.current.target.lerp(target.current, 0.075) + if (focusRequest > 0) camera.position.lerp(desiredCamera.current, 0.06) + controls.current.update() + }) + + return +} + +const OrbitPaths = ({ timestampMs, mode, scale, positions }: { + timestampMs: number + mode: VisualizationModeId + scale: DistanceScaleId + positions: Map +}) => { + const provider = mode === 'simplified' ? circularProvider : keplerianProvider + const paths = useMemo(() => CELESTIAL_BODIES.filter((body) => body.orbit).map((body) => { + const orbit = body.orbit! + const parentPosition = body.parentId ? positions.get(body.parentId) ?? [0, 0, 0] : [0, 0, 0] + const points: [number, number, number][] = [] + for (let index = 0; index <= 96; index += 1) { + const sampleTimestamp = timestampMs + (orbit.orbitalPeriodDays * 86_400_000 * index) / 96 + const local = provider.positionAt(body, sampleTimestamp) + if (body.category === 'moon') { + const length = Math.hypot(local.x, local.y, local.z) || 1 + const radius = mode === 'simplified' ? 1 : 0.5 + points.push([parentPosition[0] + local.x / length * radius, parentPosition[1] + local.z / length * radius, parentPosition[2] + local.y / length * radius]) + } else { + if (mode === 'simplified') { + const length = Math.hypot(local.x, local.y, local.z) || 1 + const displayRadius = body.simplifiedOrbitRadius ?? length + points.push([local.x / length * displayRadius, local.z / length * displayRadius, local.y / length * displayRadius]) + } else { + points.push(toScenePosition(local, mode, scale)) + } + } + } + return { id: body.id, points, isMoon: body.category === 'moon' } + }), [mode, positions, provider, scale, timestampMs]) + + return <>{paths.map((path) => )} +} + +const SceneContent = (props: SolarSystemSceneProps) => { + const provider = props.mode === 'keplerian' ? keplerianProvider : circularProvider + const calculated = useMemo( + () => calculateSystemPositions(CELESTIAL_BODIES, props.timestampMs, provider), + [props.timestampMs, provider], + ) + const displayPositions = useMemo( + () => toDisplayPositions(calculated, props.mode, props.distanceScale), + [calculated, props.distanceScale, props.mode], + ) + + return ( + <> + + + + + + {props.showOrbits && props.mode !== 'lineup' && } + {CELESTIAL_BODIES.map((body) => ( + + ))} + + + + ) +} + +export const SolarSystemScene = (props: SolarSystemSceneProps) => ( + undefined}> + + +) diff --git a/src/simulation/clock.test.ts b/src/simulation/clock.test.ts new file mode 100644 index 0000000..cf695a8 --- /dev/null +++ b/src/simulation/clock.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { advanceSimulationClock, type SimulationClockState } from './clock' + +const clock = (overrides: Partial = {}): SimulationClockState => ({ timestampMs: 1_000, rate: 2, isPlaying: true, ...overrides }) + +describe('simulation clock', () => { + it('advances independently of frame rate', () => { + expect(advanceSimulationClock(clock(), 250).timestampMs).toBe(1_500) + }) + it('does not advance while paused', () => { + expect(advanceSimulationClock(clock({ isPlaying: false }), 250).timestampMs).toBe(1_000) + }) + it('supports reverse time', () => { + expect(advanceSimulationClock(clock({ rate: -10 }), 250).timestampMs).toBe(-1_500) + }) +}) diff --git a/src/simulation/clock.ts b/src/simulation/clock.ts new file mode 100644 index 0000000..c91e8f9 --- /dev/null +++ b/src/simulation/clock.ts @@ -0,0 +1,19 @@ +export interface SimulationClockState { + timestampMs: number + rate: number + isPlaying: boolean +} + +export const advanceSimulationClock = ( + state: SimulationClockState, + realDeltaMs: number, +): SimulationClockState => { + if (!state.isPlaying || realDeltaMs <= 0) return state + return { ...state, timestampMs: state.timestampMs + realDeltaMs * state.rate } +} + +export const createSimulationClock = (timestampMs = Date.now()): SimulationClockState => ({ + timestampMs, + rate: 86_400, + isPlaying: true, +}) diff --git a/src/simulation/rotation.ts b/src/simulation/rotation.ts new file mode 100644 index 0000000..ba66760 --- /dev/null +++ b/src/simulation/rotation.ts @@ -0,0 +1,9 @@ +import type { CelestialBody } from '../domain/celestial' +import { J2000_JULIAN_DATE, timestampToJulianDate } from '../math/julian' +import { HOURS_PER_DAY, normalizeRadians } from '../math/units' + +export const rotationAngleAt = (body: CelestialBody, timestampMs: number): number => { + if (!body.siderealRotationPeriodHours) return 0 + const elapsedHours = (timestampToJulianDate(timestampMs) - J2000_JULIAN_DATE) * HOURS_PER_DAY + return normalizeRadians((elapsedHours / body.siderealRotationPeriodHours) * Math.PI * 2) +} diff --git a/src/simulation/useSimulationClock.ts b/src/simulation/useSimulationClock.ts new file mode 100644 index 0000000..18cd995 --- /dev/null +++ b/src/simulation/useSimulationClock.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { advanceSimulationClock, createSimulationClock, type SimulationClockState } from './clock' + +export interface SimulationClockController extends SimulationClockState { + setTimestamp: (timestampMs: number) => void + setRate: (rate: number) => void + togglePlaying: () => void + resetToNow: () => void +} + +export const useSimulationClock = (): SimulationClockController => { + const [clock, setClock] = useState(createSimulationClock) + const lastFrame = useRef(null) + + useEffect(() => { + let frameId = 0 + const frame = (time: number) => { + if (lastFrame.current !== null) { + const delta = Math.min(time - lastFrame.current, 250) + setClock((current) => advanceSimulationClock(current, delta)) + } + lastFrame.current = time + frameId = requestAnimationFrame(frame) + } + frameId = requestAnimationFrame(frame) + return () => cancelAnimationFrame(frameId) + }, []) + + return { + ...clock, + setTimestamp: useCallback((timestampMs: number) => setClock((current) => ({ ...current, timestampMs })), []), + setRate: useCallback((rate: number) => setClock((current) => ({ ...current, rate })), []), + togglePlaying: useCallback(() => setClock((current) => ({ ...current, isPlaying: !current.isPlaying })), []), + resetToNow: useCallback(() => setClock((current) => ({ ...current, timestampMs: Date.now() })), []), + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..b13249e --- /dev/null +++ b/src/styles.css @@ -0,0 +1,113 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #eaf1fa; + background: #030712; + font-synthesis: none; + color-scheme: dark; + --panel: rgba(9, 17, 31, 0.83); + --line: rgba(137, 168, 205, 0.18); + --muted: #8394aa; + --cyan: #81e4ff; +} + +* { box-sizing: border-box; } +html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; } +button, input, select { font: inherit; } +button, select, input { color: inherit; } +button { cursor: pointer; } + +.app-shell { height: 100%; position: relative; background: #030712; } +.topbar { + position: absolute; inset: 0 0 auto 0; height: 76px; z-index: 10; display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 25px; + background: linear-gradient(180deg, rgba(3, 7, 18, .96), rgba(3, 7, 18, .62)); + border-bottom: 1px solid var(--line); backdrop-filter: blur(18px); +} +.brand { display: flex; align-items: center; gap: 13px; } +.brand > div { display: flex; flex-direction: column; } +.brand strong { font-size: 16px; letter-spacing: .02em; } +.brand small { color: var(--muted); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; } +.brand-orbit { width: 31px; height: 31px; border: 1px solid #6090bc; border-radius: 50%; position: relative; transform: rotate(-25deg); } +.brand-orbit::before { content: ''; position: absolute; inset: 8px; border-radius: 50%; background: #ffe38b; box-shadow: 0 0 20px #ffb53e; } +.brand-orbit::after { content: ''; position: absolute; width: 5px; height: 5px; border-radius: 50%; background: var(--cyan); top: 2px; left: 2px; } + +.mode-tabs { height: 100%; display: flex; } +.mode-tabs button { min-width: 112px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); padding: 14px 18px 10px; position: relative; } +.mode-tabs button span { display: block; font-size: 12px; font-weight: 600; } +.mode-tabs button small { display: block; margin-top: 4px; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; opacity: .5; } +.mode-tabs button.active { color: #f8fbff; border-color: var(--cyan); background: linear-gradient(180deg, transparent, rgba(74, 197, 226, .08)); } +.mode-tabs button:hover { color: #fff; } + +.view-actions { justify-self: end; display: flex; gap: 8px; } +select, input { background: rgba(14, 27, 45, .92); border: 1px solid var(--line); border-radius: 7px; padding: 9px 11px; font-size: 11px; outline: none; } +select:focus, input:focus { border-color: #5bb8d2; } +.toggle { background: transparent; border: 1px solid var(--line); padding: 8px 12px; border-radius: 7px; color: var(--muted); font-size: 11px; } +.toggle::before { content: '○'; padding-right: 6px; } +.toggle.active { color: var(--cyan); border-color: rgba(129, 228, 255, .4); } +.toggle.active::before { content: '●'; } + +.viewport { position: absolute; inset: 0; } +.viewport canvas { touch-action: none; } +.panel { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 18px 50px rgba(0, 0, 0, .32); backdrop-filter: blur(16px); } +.eyebrow { font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .17em; text-transform: uppercase; color: #6f839a; } + +.mode-card { position: absolute; left: 25px; top: 98px; z-index: 5; width: 224px; padding: 16px; border-radius: 9px; display: flex; flex-direction: column; gap: 5px; } +.mode-card strong { font-size: 13px; } +.mode-card small { color: var(--muted); font-size: 10px; line-height: 1.5; } +.scene-hint { position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%); color: #5d6d81; z-index: 4; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } + +.time-controls { position: absolute; left: 25px; bottom: 25px; z-index: 6; width: 348px; border-radius: 10px; padding: 16px; } +.time-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 13px; } +.time-heading > div { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 9px; } +.time-heading .eyebrow { grid-column: 1 / -1; margin-bottom: 5px; } +.time-heading strong { font-size: 15px; } +.time-heading small { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.status-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: #5b6471; } +.status-dot.playing { background: #62e5ab; box-shadow: 0 0 10px #62e5ab; } +.time-row { display: flex; gap: 7px; margin-top: 8px; } +.time-row select { flex: 1; min-width: 0; } +.time-row input { min-width: 0; flex: 1; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.icon-button, .text-button { border: 1px solid var(--line); background: rgba(14, 27, 45, .88); border-radius: 7px; height: 34px; min-width: 35px; } +.icon-button.primary { background: var(--cyan); color: #04111a; border-color: var(--cyan); font-weight: 700; } +.text-button { padding: 0 13px; color: var(--cyan); font-size: 11px; } + +.info-panel { position: absolute; right: 25px; top: 98px; z-index: 6; width: 282px; max-height: calc(100vh - 145px); overflow-y: auto; border-radius: 10px; padding: 21px; } +.close-button { position: absolute; top: 12px; right: 13px; border: 0; background: none; color: #7d8da0; font-size: 21px; line-height: 1; } +.planet-mark { width: 51px; height: 51px; border-radius: 50%; margin-bottom: 17px; background: radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--body-color), white 32%), var(--body-color) 52%, color-mix(in srgb, var(--body-color), black 50%)); box-shadow: 0 0 25px color-mix(in srgb, var(--body-color), transparent 58%); } +.info-panel h2 { margin: 5px 0 7px; font-size: 27px; letter-spacing: -.035em; } +.info-panel > p { color: #9aaabc; font-size: 11px; line-height: 1.62; margin: 0 0 15px; } +.focus-button { width: 100%; background: rgba(51, 106, 137, .22); border: 1px solid rgba(129, 228, 255, .28); color: var(--cyan); border-radius: 7px; padding: 9px; font-size: 11px; } +.info-panel dl { margin: 17px 0; border-top: 1px solid var(--line); } +.info-panel dl div { display: flex; justify-content: space-between; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 10px; } +.info-panel dt { color: #75879c; } +.info-panel dd { margin: 0; text-align: right; } +.coordinate-block { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; } +.coordinate-block span { grid-column: 1 / -1; color: #61758b; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.coordinate-block code { color: #9fb2c8; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; } + +.body-label { border: 0; background: rgba(3, 8, 16, .76); color: #aebdce; border-radius: 4px; padding: 3px 6px; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; pointer-events: auto; } +.body-label.selected { color: var(--cyan); outline: 1px solid rgba(129, 228, 255, .42); } +.disclaimer { position: absolute; right: 25px; bottom: 11px; z-index: 4; color: #42536a; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .07em; } + +@media (max-width: 900px) { + .topbar { grid-template-columns: auto 1fr; padding: 0 14px; } + .mode-tabs { justify-self: end; } + .mode-tabs button { min-width: 80px; padding-inline: 7px; } + .view-actions { position: absolute; top: 83px; right: 12px; } + .brand small, .mode-tabs button small { display: none; } + .info-panel { right: 12px; top: 132px; width: 244px; max-height: calc(100vh - 280px); } + .mode-card { left: 12px; top: 132px; width: 190px; } + .time-controls { left: 12px; bottom: 12px; width: min(348px, calc(100vw - 24px)); } + .scene-hint, .disclaimer { display: none; } +} + +@media (max-width: 620px) { + .topbar { height: 66px; } + .brand > div { display: none; } + .mode-tabs { width: 100%; } + .mode-tabs button { flex: 1; min-width: 0; font-size: 10px; } + .view-actions { top: 73px; } + .view-actions select { display: none; } + .mode-card { display: none; } + .info-panel { inset: auto 12px 158px 12px; width: auto; max-height: 38vh; } +} diff --git a/src/ui/InfoPanel.tsx b/src/ui/InfoPanel.tsx new file mode 100644 index 0000000..e010e91 --- /dev/null +++ b/src/ui/InfoPanel.tsx @@ -0,0 +1,39 @@ +import type { CelestialBody, PositionedBody } from '../domain/celestial' +import { auToKm, radiansToDegrees } from '../math/units' + +const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }) + +export const InfoPanel = ({ positioned, onClose, onFocus }: { + positioned: PositionedBody + onClose: () => void + onFocus: () => void +}) => { + const body: CelestialBody = positioned.body + const distanceAu = Math.hypot(positioned.localPositionAu.x, positioned.localPositionAu.y, positioned.localPositionAu.z) + return ( + + ) +} diff --git a/src/ui/TimeControls.tsx b/src/ui/TimeControls.tsx new file mode 100644 index 0000000..96b9dfb --- /dev/null +++ b/src/ui/TimeControls.tsx @@ -0,0 +1,45 @@ +import type { SimulationClockController } from '../simulation/useSimulationClock' + +const SPEEDS = [ + { value: -2_592_000, label: 'Reverse month / sec' }, + { value: -86_400, label: 'Reverse day / sec' }, + { value: 1, label: 'Real time' }, + { value: 3_600, label: 'Hour / sec' }, + { value: 86_400, label: 'Day / sec' }, + { value: 2_592_000, label: 'Month / sec' }, + { value: 31_557_600, label: 'Year / sec' }, +] as const + +const toDateTimeLocal = (timestampMs: number): string => { + const date = new Date(timestampMs) + const offsetMs = date.getTimezoneOffset() * 60_000 + return new Date(timestampMs - offsetMs).toISOString().slice(0, 16) +} + +export const TimeControls = ({ clock }: { clock: SimulationClockController }) => ( +
+
+
+ Simulation time + {new Date(clock.timestampMs).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })} + {new Date(clock.timestampMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC', hour12: false })} UTC +
+ +
+
+ + + + +
+
+ { + const timestamp = new Date(event.target.value).getTime() + if (Number.isFinite(timestamp)) clock.setTimestamp(timestamp) + }} aria-label="Simulation date and time" /> + +
+
+) diff --git a/src/visualization/modes.ts b/src/visualization/modes.ts new file mode 100644 index 0000000..7234175 --- /dev/null +++ b/src/visualization/modes.ts @@ -0,0 +1,30 @@ +import type { Vector3Au } from '../domain/celestial' + +export type VisualizationModeId = 'simplified' | 'lineup' | 'keplerian' +export type DistanceScaleId = 'compressed' | 'true-distance' + +export interface VisualizationMode { + id: VisualizationModeId + label: string + shortLabel: string + description: string + providerId: 'circular' | 'keplerian' + isLineup: boolean +} + +export const VISUALIZATION_MODES: readonly VisualizationMode[] = [ + { id: 'simplified', label: 'Simplified Solar System', shortLabel: 'Overview', description: 'Circular, coplanar, intentionally compressed', providerId: 'circular', isLineup: false }, + { id: 'lineup', label: 'Planet Lineup', shortLabel: 'Size lineup', description: 'Nonlinear radius compression, independent of distance', providerId: 'circular', isLineup: true }, + { id: 'keplerian', label: '3D Educational Model', shortLabel: '3D orbits', description: 'Elliptical, inclined J2000 orbital elements', providerId: 'keplerian', isLineup: false }, +] as const + +export const toScenePosition = ( + positionAu: Vector3Au, + mode: VisualizationModeId, + scale: DistanceScaleId, +): [number, number, number] => { + if (mode === 'simplified') return [positionAu.x, positionAu.z, positionAu.y] + const radial = Math.hypot(positionAu.x, positionAu.y, positionAu.z) + const factor = scale === 'true-distance' ? 1 : radial > 0 ? (3.3 + Math.log1p(radial) * 5.2) / radial : 1 + return [positionAu.x * factor, positionAu.z * factor, positionAu.y * factor] +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..252250a --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vitest/globals"] + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..3950b4b --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c295e50 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'node', + coverage: { reporter: ['text', 'json', 'html'] }, + }, +}) From e8bb99facb7ec81b46380fcc4bc8d8b57912247f Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Tue, 11 Aug 2026 22:42:22 -0700 Subject: [PATCH 2/7] Document implementation handoff --- docs/HANDOFF.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 16a08fc..3973dd3 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,7 +3,8 @@ ## State - Branch: `codex/initial-architecture` -- Commit/PR: pending commit and PR creation +- Implementation commit: `4df27b2` +- Review: [PR #1](https://github.com/kevinatlee/SolarSystem/pull/1) (open, intentionally unmerged) - Initial vertical slice implemented: typed body catalog, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. ## Verification From 6669a78a251314c047567d1d208e9c2bb45161c8 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Tue, 11 Aug 2026 23:02:30 -0700 Subject: [PATCH 3/7] Address pre-merge architecture review --- docs/ARCHITECTURE.md | 4 ++-- docs/HANDOFF.md | 17 ++++++++++---- src/App.tsx | 8 +++++++ src/orbits/circular.ts | 14 ++++++------ src/orbits/keplerian.ts | 26 ++++------------------ src/orbits/orbits.test.ts | 30 +++++++++++++++++++++++-- src/orbits/propagation.ts | 30 +++++++++++++++++++++++++ src/scene/SolarSystemScene.tsx | 33 +++++++++++++++++++++++----- src/simulation/clock.test.ts | 3 +++ src/simulation/useSimulationClock.ts | 2 +- src/styles.css | 3 +++ src/ui/TimeControls.tsx | 25 ++++++++++++++------- 12 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 src/orbits/propagation.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d114a49..93c54e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,7 +19,7 @@ Astronomical distances and positions use AU; radii use km; orbital periods use d ## Orbital providers and hierarchy -`OrbitalPositionProvider` accepts a typed body and timestamp and returns a parent-relative AU vector. The circular provider is deliberately illustrative. The Keplerian provider solves Kepler's equation, derives orbital-plane coordinates, and applies node/inclination/periapsis rotations. `calculateSystemPositions` composes any parent chain by stable ID; Earth/Moon is the first proof of this generic mechanism. A future Horizons, DE, or SPICE adapter can implement the same provider boundary. +`OrbitalPositionProvider` accepts a typed body and timestamp and returns a parent-relative AU vector. Shared element propagation uses supplied JPL per-century rates—including mean anomaly—while bodies without rates fall back to period-based phase progression. The circular provider is deliberately illustrative but uses propagated mean longitude (`M + ω + Ω`) for a defensible coplanar phase. The Keplerian provider solves Kepler's equation, derives orbital-plane coordinates, and applies node/inclination/periapsis rotations. `calculateSystemPositions` composes any parent chain by stable ID; Earth/Moon is the first proof of this generic mechanism. A future Horizons, DE, or SPICE adapter can implement the same provider boundary. ## Visualization and scale @@ -27,6 +27,6 @@ Modes choose the provider and layout intent. `toScenePosition` is the boundary w ## Data sources and limits -Planetary orbital elements and rates use JPL Solar System Dynamics, [Approximate Positions of the Planets](https://ssd.jpl.nasa.gov/planets/approx_pos.html), Table 1 (1800–2050). Physical radii, rotation periods, and tilts are rounded from NASA/NSSDC planetary fact sheets. The Moon uses standard educational mean elements. Two-body elements omit perturbations and should never be presented as observation-grade coordinates. +Planetary orbital elements and rates use JPL Solar System Dynamics, [Approximate Positions of the Planets](https://ssd.jpl.nasa.gov/planets/approx_pos.html), Table 1 (1800–2050). The UI warns, but does not block exploration, when the Keplerian date is outside that fitted interval. Physical radii, rotation periods, and tilts are rounded from NASA/NSSDC planetary fact sheets. The Moon uses standard educational mean elements. Two-body elements omit perturbations and should never be presented as observation-grade coordinates. The visual layer updates only this small initial body set through React today. If catalogs reach hundreds or thousands of objects, migrate high-frequency body matrices to instancing and mutable render-loop buffers without changing the provider/domain boundaries. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3973dd3..bd86ba8 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,16 +3,16 @@ ## State - Branch: `codex/initial-architecture` -- Implementation commit: `4df27b2` +- Initial implementation commit: `4df27b2` - Review: [PR #1](https://github.com/kevinatlee/SolarSystem/pull/1) (open, intentionally unmerged) - Initial vertical slice implemented: typed body catalog, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. ## Verification -- `npm test`: 12/12 passing +- `npm test`: 15/15 passing, including element-rate, mean-longitude phase, and long-frame-gap regressions - `npm run typecheck`: passing - `npm run build`: passing (expected Three.js bundle-size warning) -- Browser smoke test: passing for body render, mode switch, selection, details, pause, and reverse speed; no runtime errors +- Browser smoke test: passing for all bidirectional speed presets, direction switching, out-of-range warning, and focus action; no runtime errors - Production preview: root and direct `/explore/earth` navigation both returned HTTP 200 - Docker: execution not available because the host has no `docker` command; Dockerfile, Compose, nginx health endpoint, and SPA fallback were inspected but not executed @@ -23,6 +23,15 @@ - Body radii are exaggerated outside lineup; lineup explicitly applies nonlinear radius compression. Moon orbital display radius is enlarged. - Camera focus eases to a body; persistent follow mode and high-volume instancing are future work. +## Pre-merge review corrections + +- Propagate JPL mean anomaly from its supplied per-century rate, retaining period fallback for the Moon. +- Base simplified circular phase on propagated mean longitude. +- Preserve full elapsed time across long animation-frame gaps. +- Release camera control after a one-shot focus transition or immediate user interaction. +- Provide forward and reverse variants of every speed magnitude. +- Warn when the Keplerian date is outside JPL's 1800–2050 fitted interval. + ## Recommended next task -Architecture/code review and hands-on interaction testing, followed by targeted fixes before squash merge. A subsequent visual milestone can add better procedural materials and camera-follow behavior without changing the simulation layer. +Final review and hands-on interaction/container testing before squash merge. A subsequent visual milestone can add better procedural materials and camera-follow behavior without changing the simulation layer. diff --git a/src/App.tsx b/src/App.tsx index 703cd92..78d4193 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,9 @@ export const App = () => { [clock.timestampMs, mode], ) const selected = selectedId ? positions.get(selectedId) : undefined + const simulationYear = new Date(clock.timestampMs).getUTCFullYear() + const isOutsideKeplerianRange = + mode === 'keplerian' && (simulationYear < 1800 || simulationYear > 2050) return (
@@ -49,6 +52,11 @@ export const App = () => { {activeMode.label} {activeMode.description} + {isOutsideKeplerianRange && ( +
+ JPL planetary elements are fitted for 1800–2050. Outside that range, this educational model may be substantially less accurate. +
+ )}
Drag to orbit · Scroll to zoom · Click a body
{selected && setSelectedId(null)} onFocus={() => setFocusRequest((value) => value + 1)} />} diff --git a/src/orbits/circular.ts b/src/orbits/circular.ts index bb77e3a..112cb78 100644 --- a/src/orbits/circular.ts +++ b/src/orbits/circular.ts @@ -3,6 +3,7 @@ import { timestampToJulianDate } from '../math/julian' import { normalizeRadians } from '../math/units' import type { OrbitalPositionProvider } from './provider' import { ZERO_VECTOR } from './provider' +import { propagateOrbitalElements } from './propagation' export const circularPosition = ( radius: number, @@ -19,12 +20,11 @@ export const circularProvider: OrbitalPositionProvider = { label: 'Simplified circular model', positionAt(body: CelestialBody, timestampMs: number): Vector3Au { if (!body.orbit) return ZERO_VECTOR - const elapsedDays = timestampToJulianDate(timestampMs) - body.orbit.epochJulianDate - return circularPosition( - body.orbit.semiMajorAxisAu, - body.orbit.orbitalPeriodDays, - elapsedDays, - body.orbit.meanAnomalyAtEpochRad, - ) + const elements = propagateOrbitalElements(body.orbit, timestampToJulianDate(timestampMs)) + const meanLongitude = + elements.meanAnomalyRad + + elements.argumentPeriapsisRad + + elements.longitudeAscendingNodeRad + return circularPosition(elements.semiMajorAxisAu, elements.orbitalPeriodDays, 0, meanLongitude) }, } diff --git a/src/orbits/keplerian.ts b/src/orbits/keplerian.ts index c917d34..a563a6a 100644 --- a/src/orbits/keplerian.ts +++ b/src/orbits/keplerian.ts @@ -1,8 +1,9 @@ import type { CelestialBody, OrbitalElements, Vector3Au } from '../domain/celestial' import { timestampToJulianDate } from '../math/julian' -import { DAYS_PER_JULIAN_CENTURY, normalizeRadians } from '../math/units' +import { normalizeRadians } from '../math/units' import type { OrbitalPositionProvider } from './provider' import { ZERO_VECTOR } from './provider' +import { propagateOrbitalElements } from './propagation' export const solveEccentricAnomaly = ( meanAnomalyRad: number, @@ -26,31 +27,12 @@ export const solveEccentricAnomaly = ( throw new Error('Kepler equation did not converge') } -const elementsAt = (elements: OrbitalElements, julianDate: number): OrbitalElements => { - const centuries = (julianDate - elements.epochJulianDate) / DAYS_PER_JULIAN_CENTURY - const rates = elements.ratesPerJulianCentury - if (!rates) return elements - return { - ...elements, - semiMajorAxisAu: elements.semiMajorAxisAu + (rates.semiMajorAxisAu ?? 0) * centuries, - eccentricity: elements.eccentricity + (rates.eccentricity ?? 0) * centuries, - inclinationRad: elements.inclinationRad + (rates.inclinationRad ?? 0) * centuries, - longitudeAscendingNodeRad: - elements.longitudeAscendingNodeRad + (rates.longitudeAscendingNodeRad ?? 0) * centuries, - argumentPeriapsisRad: - elements.argumentPeriapsisRad + (rates.argumentPeriapsisRad ?? 0) * centuries, - } -} - export const keplerianPositionAtJulianDate = ( sourceElements: OrbitalElements, julianDate: number, ): Vector3Au => { - const elements = elementsAt(sourceElements, julianDate) - const elapsedDays = julianDate - elements.epochJulianDate - const meanMotion = (Math.PI * 2) / elements.orbitalPeriodDays - const meanAnomaly = elements.meanAnomalyAtEpochRad + meanMotion * elapsedDays - const eccentricAnomaly = solveEccentricAnomaly(meanAnomaly, elements.eccentricity) + const elements = propagateOrbitalElements(sourceElements, julianDate) + const eccentricAnomaly = solveEccentricAnomaly(elements.meanAnomalyRad, elements.eccentricity) const xOrbital = elements.semiMajorAxisAu * (Math.cos(eccentricAnomaly) - elements.eccentricity) const yOrbital = diff --git a/src/orbits/orbits.test.ts b/src/orbits/orbits.test.ts index 41da12e..5888df1 100644 --- a/src/orbits/orbits.test.ts +++ b/src/orbits/orbits.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { CelestialBody, OrbitalElements } from '../domain/celestial' -import { J2000_JULIAN_DATE } from '../math/julian' -import { circularPosition } from './circular' +import { J2000_JULIAN_DATE, julianDateToTimestamp } from '../math/julian' +import { circularPosition, circularProvider } from './circular' import { keplerianPositionAtJulianDate, solveEccentricAnomaly } from './keplerian' import { calculateSystemPositions } from './system' @@ -39,6 +39,32 @@ describe('orbital math', () => { expect(position.z).toBeCloseTo(2, 10) }) + it('propagates mean anomaly from an element rate when supplied', () => { + const rateDriven = { + ...circularElements, + orbitalPeriodDays: 36_525, + ratesPerJulianCentury: { meanAnomalyRad: Math.PI / 2 }, + } + const position = keplerianPositionAtJulianDate(rateDriven, J2000_JULIAN_DATE + 36_525) + expect(position.x).toBeCloseTo(0, 10) + expect(position.y).toBeCloseTo(2, 10) + }) + + it('uses mean longitude rather than mean anomaly as simplified phase', () => { + const oriented: CelestialBody = { + id: 'oriented', name: 'Oriented', category: 'planet', radiusKm: 1, + orbit: { + ...circularElements, + argumentPeriapsisRad: Math.PI / 2, + longitudeAscendingNodeRad: Math.PI / 2, + }, + visual: { color: '#fff', accent: '#fff' }, description: '', + } + const position = circularProvider.positionAt(oriented, julianDateToTimestamp(J2000_JULIAN_DATE)) + expect(position.x).toBeCloseTo(-2, 10) + expect(position.y).toBeCloseTo(0, 10) + }) + it('calculates circular motion and period repetition', () => { expect(circularPosition(3, 12, 3).x).toBeCloseTo(0, 10) expect(circularPosition(3, 12, 12).x).toBeCloseTo(3, 10) diff --git a/src/orbits/propagation.ts b/src/orbits/propagation.ts new file mode 100644 index 0000000..76b74cb --- /dev/null +++ b/src/orbits/propagation.ts @@ -0,0 +1,30 @@ +import type { OrbitalElements } from '../domain/celestial' +import { DAYS_PER_JULIAN_CENTURY } from '../math/units' + +export interface PropagatedOrbitalElements extends OrbitalElements { + meanAnomalyRad: number +} + +export const propagateOrbitalElements = ( + elements: OrbitalElements, + julianDate: number, +): PropagatedOrbitalElements => { + const elapsedDays = julianDate - elements.epochJulianDate + const centuries = elapsedDays / DAYS_PER_JULIAN_CENTURY + const rates = elements.ratesPerJulianCentury + const meanAnomalyRad = rates?.meanAnomalyRad !== undefined + ? elements.meanAnomalyAtEpochRad + rates.meanAnomalyRad * centuries + : elements.meanAnomalyAtEpochRad + (elapsedDays / elements.orbitalPeriodDays) * Math.PI * 2 + + return { + ...elements, + semiMajorAxisAu: elements.semiMajorAxisAu + (rates?.semiMajorAxisAu ?? 0) * centuries, + eccentricity: elements.eccentricity + (rates?.eccentricity ?? 0) * centuries, + inclinationRad: elements.inclinationRad + (rates?.inclinationRad ?? 0) * centuries, + longitudeAscendingNodeRad: + elements.longitudeAscendingNodeRad + (rates?.longitudeAscendingNodeRad ?? 0) * centuries, + argumentPeriapsisRad: + elements.argumentPeriapsisRad + (rates?.argumentPeriapsisRad ?? 0) * centuries, + meanAnomalyRad, + } +} diff --git a/src/scene/SolarSystemScene.tsx b/src/scene/SolarSystemScene.tsx index aff5d8b..de2f898 100644 --- a/src/scene/SolarSystemScene.tsx +++ b/src/scene/SolarSystemScene.tsx @@ -1,7 +1,7 @@ import { Html, Line, OrbitControls, Stars } from '@react-three/drei' import { Canvas, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' -import type { Group, Mesh } from 'three' +import type { Mesh } from 'three' import { Vector3 } from 'three' import { CELESTIAL_BODIES, bodyById } from '../data/bodies' import type { CelestialBody, PositionedBody, Vector3Au } from '../domain/celestial' @@ -151,23 +151,46 @@ const CameraController = ({ selectedId, focusRequest, displayPositions, mode }: const { camera } = useThree() const target = useRef(new Vector3()) const desiredCamera = useRef(new Vector3()) + const isFocusing = useRef(false) + const handledFocusRequest = useRef(0) useEffect(() => { + if (focusRequest === 0 || focusRequest === handledFocusRequest.current) return + handledFocusRequest.current = focusRequest const point = selectedId ? displayPositions.get(selectedId) : undefined const nextTarget = point ? new Vector3(...point) : new Vector3(0, 0, 0) const distance = selectedId ? (mode === 'lineup' ? 5 : 4) : (mode === 'lineup' ? 31 : 23) target.current.copy(nextTarget) desiredCamera.current.set(nextTarget.x + distance * 0.48, nextTarget.y + distance * 0.48, nextTarget.z + distance) - }, [camera, displayPositions, focusRequest, mode, selectedId]) + isFocusing.current = true + }, [displayPositions, focusRequest, mode, selectedId]) useFrame(() => { - if (!controls.current) return + if (!controls.current || !isFocusing.current) return controls.current.target.lerp(target.current, 0.075) - if (focusRequest > 0) camera.position.lerp(desiredCamera.current, 0.06) + camera.position.lerp(desiredCamera.current, 0.06) controls.current.update() + if ( + controls.current.target.distanceTo(target.current) < 0.01 && + camera.position.distanceTo(desiredCamera.current) < 0.02 + ) { + controls.current.target.copy(target.current) + camera.position.copy(desiredCamera.current) + controls.current.update() + isFocusing.current = false + } }) - return + return ( + { isFocusing.current = false }} + /> + ) } const OrbitPaths = ({ timestampMs, mode, scale, positions }: { diff --git a/src/simulation/clock.test.ts b/src/simulation/clock.test.ts index cf695a8..b4be104 100644 --- a/src/simulation/clock.test.ts +++ b/src/simulation/clock.test.ts @@ -13,4 +13,7 @@ describe('simulation clock', () => { it('supports reverse time', () => { expect(advanceSimulationClock(clock({ rate: -10 }), 250).timestampMs).toBe(-1_500) }) + it('preserves a long frame gap at accelerated simulation rates', () => { + expect(advanceSimulationClock(clock({ rate: 86_400 }), 5_000).timestampMs).toBe(432_001_000) + }) }) diff --git a/src/simulation/useSimulationClock.ts b/src/simulation/useSimulationClock.ts index 18cd995..b1c35ed 100644 --- a/src/simulation/useSimulationClock.ts +++ b/src/simulation/useSimulationClock.ts @@ -16,7 +16,7 @@ export const useSimulationClock = (): SimulationClockController => { let frameId = 0 const frame = (time: number) => { if (lastFrame.current !== null) { - const delta = Math.min(time - lastFrame.current, 250) + const delta = time - lastFrame.current setClock((current) => advanceSimulationClock(current, delta)) } lastFrame.current = time diff --git a/src/styles.css b/src/styles.css index b13249e..56e6e40 100644 --- a/src/styles.css +++ b/src/styles.css @@ -54,6 +54,7 @@ select:focus, input:focus { border-color: #5bb8d2; } .mode-card { position: absolute; left: 25px; top: 98px; z-index: 5; width: 224px; padding: 16px; border-radius: 9px; display: flex; flex-direction: column; gap: 5px; } .mode-card strong { font-size: 13px; } .mode-card small { color: var(--muted); font-size: 10px; line-height: 1.5; } +.validity-warning { position: absolute; left: 25px; top: 207px; z-index: 5; width: 290px; padding: 11px 13px; border-color: rgba(244, 187, 91, .35); border-radius: 8px; color: #e6c98e; font-size: 10px; line-height: 1.5; } .scene-hint { position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%); color: #5d6d81; z-index: 4; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } .time-controls { position: absolute; left: 25px; bottom: 25px; z-index: 6; width: 348px; border-radius: 10px; padding: 16px; } @@ -97,6 +98,7 @@ select:focus, input:focus { border-color: #5bb8d2; } .brand small, .mode-tabs button small { display: none; } .info-panel { right: 12px; top: 132px; width: 244px; max-height: calc(100vh - 280px); } .mode-card { left: 12px; top: 132px; width: 190px; } + .validity-warning { left: 12px; top: 241px; width: 244px; } .time-controls { left: 12px; bottom: 12px; width: min(348px, calc(100vw - 24px)); } .scene-hint, .disclaimer { display: none; } } @@ -109,5 +111,6 @@ select:focus, input:focus { border-color: #5bb8d2; } .view-actions { top: 73px; } .view-actions select { display: none; } .mode-card { display: none; } + .validity-warning { top: 115px; width: calc(100vw - 24px); } .info-panel { inset: auto 12px 158px 12px; width: auto; max-height: 38vh; } } diff --git a/src/ui/TimeControls.tsx b/src/ui/TimeControls.tsx index 96b9dfb..e84523b 100644 --- a/src/ui/TimeControls.tsx +++ b/src/ui/TimeControls.tsx @@ -1,15 +1,24 @@ import type { SimulationClockController } from '../simulation/useSimulationClock' -const SPEEDS = [ - { value: -2_592_000, label: 'Reverse month / sec' }, - { value: -86_400, label: 'Reverse day / sec' }, - { value: 1, label: 'Real time' }, - { value: 3_600, label: 'Hour / sec' }, - { value: 86_400, label: 'Day / sec' }, - { value: 2_592_000, label: 'Month / sec' }, - { value: 31_557_600, label: 'Year / sec' }, +const SPEED_MAGNITUDES = [ + { value: 1, label: 'real time' }, + { value: 3_600, label: 'hour / sec' }, + { value: 86_400, label: 'day / sec' }, + { value: 2_592_000, label: 'month / sec' }, + { value: 31_557_600, label: 'year / sec' }, ] as const +const SPEEDS = [ + ...[...SPEED_MAGNITUDES].reverse().map((speed) => ({ + value: -speed.value, + label: `Reverse ${speed.label}`, + })), + ...SPEED_MAGNITUDES.map((speed) => ({ + value: speed.value, + label: speed.value === 1 ? 'Real time' : `Forward ${speed.label}`, + })), +] + const toDateTimeLocal = (timestampMs: number): string => { const date = new Date(timestampMs) const offsetMs = date.getTimezoneOffset() * 60_000 From 653436a90ee6af686eb50b4dd5eed721019e73f1 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Wed, 12 Aug 2026 08:28:55 -0700 Subject: [PATCH 4/7] Polish visual comparison modes --- docs/ARCHITECTURE.md | 4 +- docs/HANDOFF.md | 11 +++- src/App.tsx | 53 +++++++++++++--- src/scene/SolarSystemScene.tsx | 93 ++++++++++++++++++++++------- src/styles.css | 18 +++--- src/visualization/bodyScale.test.ts | 20 +++++++ src/visualization/bodyScale.ts | 21 +++++++ src/visualization/modes.ts | 2 + 8 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 src/visualization/bodyScale.test.ts create mode 100644 src/visualization/bodyScale.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 93c54e0..91df56a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,7 +23,9 @@ Astronomical distances and positions use AU; radii use km; orbital periods use d ## Visualization and scale -Modes choose the provider and layout intent. `toScenePosition` is the boundary where physical AU positions become view coordinates. Distance scale and body radius scale are independent: true-distance ratios can coexist with visible exaggerated bodies. The lineup bypasses orbital positions entirely and performs a display-only size layout. +Modes choose the provider and layout intent. `toScenePosition` is the boundary where physical AU positions become view coordinates. Distance scale and body radius scale are independent controls with separate calculations: true-distance ratios can coexist with either readable exaggerated bodies or an orbit-safe compact presentation. Selecting true distance chooses compact bodies by default, while the explicit body-size control keeps the exaggerated option available. True-distance mode also retains the Moon's physical parent-relative distance instead of its educational orbit enlargement. The lineup bypasses orbital positions entirely, performs a display-only size layout, and uses neutral presentation lighting rather than the orbital Sun light. + +Orbit paths are a presentation concern with off, subdued unified-colour, and tasteful body-colour states. The Keplerian scene retains true orbital inclination, an angled camera, and a subtle labelled J2000 ecliptic reference plane; vertical geometry is not exaggerated. ## Data sources and limits diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bd86ba8..9e1f1fe 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -9,10 +9,11 @@ ## Verification -- `npm test`: 15/15 passing, including element-rate, mean-longitude phase, and long-frame-gap regressions +- `npm test`: 17/17 passing, including element-rate, mean-longitude phase, long-frame-gap, and body-scale policy regressions - `npm run typecheck`: passing - `npm run build`: passing (expected Three.js bundle-size warning) - Browser smoke test: passing for all bidirectional speed presets, direction switching, out-of-range warning, and focus action; no runtime errors +- Latest visual-polish browser automation: unavailable in the current agent session because the in-app Browser control runtime was not exposed; local development HTTP smoke returned 200. Hands-on verification remains required for lineup lighting, True Distance inner bodies, orbit display states, and angled inclination presentation. - Production preview: root and direct `/explore/earth` navigation both returned HTTP 200 - Docker: execution not available because the host has no `docker` command; Dockerfile, Compose, nginx health endpoint, and SPA fallback were inspected but not executed @@ -35,3 +36,11 @@ ## Recommended next task Final review and hands-on interaction/container testing before squash merge. A subsequent visual milestone can add better procedural materials and camera-follow behavior without changing the simulation layer. + +## Hands-on visual review polish + +- Size Lineup uses neutral hemisphere/key/fill lighting so every body remains evenly legible and three-dimensional. +- Body size is an explicit presentation control; True Distance selects the compact orbit-safe policy by default while retaining readable exaggeration as an option. +- Orbit display supports Off, subdued Unified colour, and tasteful Body colours. +- Visualization-mode changes ease to appropriate camera presets; the Keplerian preset is angled over a subtle labelled J2000 ecliptic reference without changing true inclinations or exaggerating Z. +- Verification: 17 tests, strict typecheck, and production build pass; local development HTTP smoke returns 200. Focused visual browser automation remains pending due to unavailable Browser control in this session. diff --git a/src/App.tsx b/src/App.tsx index 78d4193..53c3836 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,15 +7,22 @@ import { SolarSystemScene } from './scene/SolarSystemScene' import { useSimulationClock } from './simulation/useSimulationClock' import { InfoPanel } from './ui/InfoPanel' import { TimeControls } from './ui/TimeControls' -import { VISUALIZATION_MODES, type DistanceScaleId, type VisualizationModeId } from './visualization/modes' +import { + VISUALIZATION_MODES, + type BodySizeScaleId, + type DistanceScaleId, + type OrbitDisplayId, + type VisualizationModeId, +} from './visualization/modes' export const App = () => { const clock = useSimulationClock() const [mode, setMode] = useState('simplified') const [distanceScale, setDistanceScale] = useState('compressed') + const [bodySizeScale, setBodySizeScale] = useState('readable') + const [orbitDisplay, setOrbitDisplay] = useState('unified') const [selectedId, setSelectedId] = useState('earth') const [focusRequest, setFocusRequest] = useState(0) - const [showOrbits, setShowOrbits] = useState(true) const activeMode = VISUALIZATION_MODES.find((item) => item.id === mode)! const positions = useMemo( () => calculateSystemPositions(CELESTIAL_BODIES, clock.timestampMs, mode === 'keplerian' ? keplerianProvider : circularProvider), @@ -38,15 +45,40 @@ export const App = () => { ))}
- {mode !== 'lineup' && } - + {mode !== 'lineup' && ( + <> + + + + + )}
- +
Active model {activeMode.label} @@ -57,6 +89,11 @@ export const App = () => { JPL planetary elements are fitted for 1800–2050. Outside that range, this educational model may be substantially less accurate.
)} + {mode === 'keplerian' && ( +
+ J2000 ecliptic reference plane · inclinations shown at true scale +
+ )}
Drag to orbit · Scroll to zoom · Click a body
{selected && setSelectedId(null)} onFocus={() => setFocusRequest((value) => value + 1)} />} diff --git a/src/scene/SolarSystemScene.tsx b/src/scene/SolarSystemScene.tsx index de2f898..6f56032 100644 --- a/src/scene/SolarSystemScene.tsx +++ b/src/scene/SolarSystemScene.tsx @@ -9,9 +9,12 @@ import { circularProvider } from '../orbits/circular' import { keplerianProvider } from '../orbits/keplerian' import { calculateSystemPositions } from '../orbits/system' import { rotationAngleAt } from '../simulation/rotation' +import { sceneRadiusForBody } from '../visualization/bodyScale' import { toScenePosition, + type BodySizeScaleId, type DistanceScaleId, + type OrbitDisplayId, type VisualizationModeId, } from '../visualization/modes' @@ -19,30 +22,21 @@ interface SolarSystemSceneProps { timestampMs: number mode: VisualizationModeId distanceScale: DistanceScaleId + bodySizeScale: BodySizeScaleId selectedId: string | null focusRequest: number - showOrbits: boolean + orbitDisplay: OrbitDisplayId onSelect: (id: string) => void } const planetIds = CELESTIAL_BODIES.filter((body) => body.category !== 'moon').map((body) => body.id) -const sceneRadius = (body: CelestialBody, mode: VisualizationModeId): number => { - if (mode === 'lineup') { - if (body.id === 'sun') return 3.6 - return Math.max(0.22, Math.sqrt(body.radiusKm / 6_371) * 0.54) - } - if (body.id === 'sun') return 0.72 - if (body.category === 'moon') return 0.105 - return Math.max(0.13, Math.pow(body.radiusKm / 6_371, 0.38) * 0.2) -} - const lineupPositions = (): Map => { const positions = new Map() let cursor = -14 planetIds.forEach((id) => { const body = bodyById.get(id)! - const radius = sceneRadius(body, 'lineup') + const radius = sceneRadiusForBody(body, 'lineup', 'readable') cursor += radius positions.set(id, [cursor, 0, 0]) cursor += radius + (id === 'sun' ? 2.2 : 0.8) @@ -76,6 +70,10 @@ const toDisplayPositions = ( const earth = display.get('earth') if (moon && earth) { const local = moon.localPositionAu + if (mode === 'keplerian' && scale === 'true-distance') { + display.set('moon', [earth[0] + local.x, earth[1] + local.z, earth[2] + local.y]) + return display + } const length = Math.hypot(local.x, local.y, local.z) || 1 const exaggerated = mode === 'simplified' ? 1 : 0.5 display.set('moon', [ @@ -92,17 +90,18 @@ interface BodyMeshProps { position: [number, number, number] timestampMs: number mode: VisualizationModeId + bodySizeScale: BodySizeScaleId selected: boolean onSelect: (id: string) => void } -const BodyMesh = ({ body, position, timestampMs, mode, selected, onSelect }: BodyMeshProps) => { +const BodyMesh = ({ body, position, timestampMs, mode, bodySizeScale, selected, onSelect }: BodyMeshProps) => { const mesh = useRef(null) const tilt = body.axialTiltRad ?? 0 useEffect(() => { if (mesh.current) mesh.current.rotation.y = rotationAngleAt(body, timestampMs) }, [body, timestampMs]) - const radius = sceneRadius(body, mode) + const radius = sceneRadiusForBody(body, mode, bodySizeScale) return ( @@ -153,6 +152,20 @@ const CameraController = ({ selectedId, focusRequest, displayPositions, mode }: const desiredCamera = useRef(new Vector3()) const isFocusing = useRef(false) const handledFocusRequest = useRef(0) + const previousMode = useRef(mode) + + useEffect(() => { + if (previousMode.current === mode) return + previousMode.current = mode + target.current.set(0, 0, 0) + const preset = mode === 'lineup' + ? new Vector3(4, 5, 30) + : mode === 'keplerian' + ? new Vector3(12, 14, 18) + : new Vector3(9, 12, 21) + desiredCamera.current.copy(preset) + isFocusing.current = true + }, [mode]) useEffect(() => { if (focusRequest === 0 || focusRequest === handledFocusRequest.current) return @@ -193,11 +206,12 @@ const CameraController = ({ selectedId, focusRequest, displayPositions, mode }: ) } -const OrbitPaths = ({ timestampMs, mode, scale, positions }: { +const OrbitPaths = ({ timestampMs, mode, scale, positions, display }: { timestampMs: number mode: VisualizationModeId scale: DistanceScaleId positions: Map + display: Exclude }) => { const provider = mode === 'simplified' ? circularProvider : keplerianProvider const paths = useMemo(() => CELESTIAL_BODIES.filter((body) => body.orbit).map((body) => { @@ -208,6 +222,10 @@ const OrbitPaths = ({ timestampMs, mode, scale, positions }: { const sampleTimestamp = timestampMs + (orbit.orbitalPeriodDays * 86_400_000 * index) / 96 const local = provider.positionAt(body, sampleTimestamp) if (body.category === 'moon') { + if (mode === 'keplerian' && scale === 'true-distance') { + points.push([parentPosition[0] + local.x, parentPosition[1] + local.z, parentPosition[2] + local.y]) + continue + } const length = Math.hypot(local.x, local.y, local.z) || 1 const radius = mode === 'simplified' ? 1 : 0.5 points.push([parentPosition[0] + local.x / length * radius, parentPosition[1] + local.z / length * radius, parentPosition[2] + local.y / length * radius]) @@ -221,10 +239,19 @@ const OrbitPaths = ({ timestampMs, mode, scale, positions }: { } } } - return { id: body.id, points, isMoon: body.category === 'moon' } + return { id: body.id, points, isMoon: body.category === 'moon', color: body.visual.color } }), [mode, positions, provider, scale, timestampMs]) - return <>{paths.map((path) => )} + return <>{paths.map((path) => ( + + ))} } const SceneContent = (props: SolarSystemSceneProps) => { @@ -242,21 +269,41 @@ const SceneContent = (props: SolarSystemSceneProps) => { <> - - + {props.mode === 'lineup' ? ( + <> + + + + + ) : ( + <> + + + + )} - {props.showOrbits && props.mode !== 'lineup' && } + {props.orbitDisplay !== 'off' && props.mode !== 'lineup' && } {CELESTIAL_BODIES.map((body) => ( - + ))} - + {props.mode === 'keplerian' && ( + <> + + + + + + + + + )} ) } export const SolarSystemScene = (props: SolarSystemSceneProps) => ( - undefined}> + undefined}> ) diff --git a/src/styles.css b/src/styles.css index 56e6e40..9c33fd7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -38,13 +38,11 @@ button { cursor: pointer; } .mode-tabs button.active { color: #f8fbff; border-color: var(--cyan); background: linear-gradient(180deg, transparent, rgba(74, 197, 226, .08)); } .mode-tabs button:hover { color: #fff; } -.view-actions { justify-self: end; display: flex; gap: 8px; } +.view-actions { justify-self: end; display: flex; align-items: center; gap: 7px; } +.select-control { display: flex; flex-direction: column; gap: 3px; color: #60748a; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } +.select-control select { min-width: 112px; padding: 6px 8px; text-transform: none; letter-spacing: 0; } select, input { background: rgba(14, 27, 45, .92); border: 1px solid var(--line); border-radius: 7px; padding: 9px 11px; font-size: 11px; outline: none; } select:focus, input:focus { border-color: #5bb8d2; } -.toggle { background: transparent; border: 1px solid var(--line); padding: 8px 12px; border-radius: 7px; color: var(--muted); font-size: 11px; } -.toggle::before { content: '○'; padding-right: 6px; } -.toggle.active { color: var(--cyan); border-color: rgba(129, 228, 255, .4); } -.toggle.active::before { content: '●'; } .viewport { position: absolute; inset: 0; } .viewport canvas { touch-action: none; } @@ -55,6 +53,8 @@ select:focus, input:focus { border-color: #5bb8d2; } .mode-card strong { font-size: 13px; } .mode-card small { color: var(--muted); font-size: 10px; line-height: 1.5; } .validity-warning { position: absolute; left: 25px; top: 207px; z-index: 5; width: 290px; padding: 11px 13px; border-color: rgba(244, 187, 91, .35); border-radius: 8px; color: #e6c98e; font-size: 10px; line-height: 1.5; } +.ecliptic-key { position: absolute; left: 50%; top: 96px; z-index: 4; transform: translateX(-50%); display: flex; align-items: center; gap: 7px; color: #60748a; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } +.ecliptic-key span { width: 24px; height: 1px; background: #315779; box-shadow: 0 0 8px rgba(79, 130, 172, .35); } .scene-hint { position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%); color: #5d6d81; z-index: 4; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } .time-controls { position: absolute; left: 25px; bottom: 25px; z-index: 6; width: 348px; border-radius: 10px; padding: 16px; } @@ -94,10 +94,11 @@ select:focus, input:focus { border-color: #5bb8d2; } .topbar { grid-template-columns: auto 1fr; padding: 0 14px; } .mode-tabs { justify-self: end; } .mode-tabs button { min-width: 80px; padding-inline: 7px; } - .view-actions { position: absolute; top: 83px; right: 12px; } + .view-actions { position: absolute; top: 83px; right: 12px; padding: 8px; background: rgba(4, 10, 20, .84); border: 1px solid var(--line); border-radius: 9px; backdrop-filter: blur(14px); } .brand small, .mode-tabs button small { display: none; } .info-panel { right: 12px; top: 132px; width: 244px; max-height: calc(100vh - 280px); } .mode-card { left: 12px; top: 132px; width: 190px; } + .ecliptic-key { top: 154px; } .validity-warning { left: 12px; top: 241px; width: 244px; } .time-controls { left: 12px; bottom: 12px; width: min(348px, calc(100vw - 24px)); } .scene-hint, .disclaimer { display: none; } @@ -109,8 +110,11 @@ select:focus, input:focus { border-color: #5bb8d2; } .mode-tabs { width: 100%; } .mode-tabs button { flex: 1; min-width: 0; font-size: 10px; } .view-actions { top: 73px; } - .view-actions select { display: none; } + .view-actions { left: 12px; right: 12px; overflow-x: auto; } + .select-control { flex: 1; min-width: 118px; } + .select-control select { width: 100%; min-width: 0; } .mode-card { display: none; } + .ecliptic-key { display: none; } .validity-warning { top: 115px; width: calc(100vw - 24px); } .info-panel { inset: auto 12px 158px 12px; width: auto; max-height: 38vh; } } diff --git a/src/visualization/bodyScale.test.ts b/src/visualization/bodyScale.test.ts new file mode 100644 index 0000000..b27eb5e --- /dev/null +++ b/src/visualization/bodyScale.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { bodyById } from '../data/bodies' +import { sceneRadiusForBody } from './bodyScale' + +describe('body presentation scale', () => { + it('keeps body size independent from distance scale', () => { + const earth = bodyById.get('earth')! + expect(sceneRadiusForBody(earth, 'keplerian', 'compact')).toBe( + sceneRadiusForBody(earth, 'simplified', 'compact'), + ) + }) + + it('offers an orbit-safe Sun size and a clearly exaggerated alternative', () => { + const sun = bodyById.get('sun')! + const compact = sceneRadiusForBody(sun, 'keplerian', 'compact') + const readable = sceneRadiusForBody(sun, 'keplerian', 'readable') + expect(compact).toBeLessThan(0.38709927 / 2) + expect(readable).toBeGreaterThan(compact * 4) + }) +}) diff --git a/src/visualization/bodyScale.ts b/src/visualization/bodyScale.ts new file mode 100644 index 0000000..2f72b91 --- /dev/null +++ b/src/visualization/bodyScale.ts @@ -0,0 +1,21 @@ +import type { CelestialBody } from '../domain/celestial' +import type { BodySizeScaleId, VisualizationModeId } from './modes' + +export const sceneRadiusForBody = ( + body: CelestialBody, + mode: VisualizationModeId, + bodySizeScale: BodySizeScaleId, +): number => { + if (mode === 'lineup') { + if (body.id === 'sun') return 3.6 + return Math.max(0.22, Math.sqrt(body.radiusKm / 6_371) * 0.54) + } + if (bodySizeScale === 'compact') { + if (body.id === 'sun') return 0.14 + if (body.category === 'moon') return 0.018 + return Math.max(0.022, Math.sqrt(body.radiusKm / 6_371) * 0.032) + } + if (body.id === 'sun') return 0.72 + if (body.category === 'moon') return 0.105 + return Math.max(0.13, Math.pow(body.radiusKm / 6_371, 0.38) * 0.2) +} diff --git a/src/visualization/modes.ts b/src/visualization/modes.ts index 7234175..9c26ce0 100644 --- a/src/visualization/modes.ts +++ b/src/visualization/modes.ts @@ -2,6 +2,8 @@ import type { Vector3Au } from '../domain/celestial' export type VisualizationModeId = 'simplified' | 'lineup' | 'keplerian' export type DistanceScaleId = 'compressed' | 'true-distance' +export type BodySizeScaleId = 'readable' | 'compact' +export type OrbitDisplayId = 'off' | 'unified' | 'body-colors' export interface VisualizationMode { id: VisualizationModeId From ab4e354a6fdabde55d2da6cd865732d7a9059128 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Wed, 12 Aug 2026 08:29:15 -0700 Subject: [PATCH 5/7] Update visual polish handoff --- docs/HANDOFF.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 9e1f1fe..2a7a6f5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,6 +4,7 @@ - Branch: `codex/initial-architecture` - Initial implementation commit: `4df27b2` +- Latest visual-polish commit: `653436a` - Review: [PR #1](https://github.com/kevinatlee/SolarSystem/pull/1) (open, intentionally unmerged) - Initial vertical slice implemented: typed body catalog, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. From ade917d8178d2e193639c8820411557a9de1a4fe Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Wed, 12 Aug 2026 08:34:20 -0700 Subject: [PATCH 6/7] Prevent true-distance satellite overlap --- docs/ARCHITECTURE.md | 2 +- docs/HANDOFF.md | 10 ++++++-- src/scene/SolarSystemScene.tsx | 38 +++++++++++++++++++++++------ src/visualization/bodyScale.test.ts | 30 +++++++++++++++++++++-- src/visualization/bodyScale.ts | 27 +++++++++++++++++++- 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 91df56a..52934b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,7 +23,7 @@ Astronomical distances and positions use AU; radii use km; orbital periods use d ## Visualization and scale -Modes choose the provider and layout intent. `toScenePosition` is the boundary where physical AU positions become view coordinates. Distance scale and body radius scale are independent controls with separate calculations: true-distance ratios can coexist with either readable exaggerated bodies or an orbit-safe compact presentation. Selecting true distance chooses compact bodies by default, while the explicit body-size control keeps the exaggerated option available. True-distance mode also retains the Moon's physical parent-relative distance instead of its educational orbit enlargement. The lineup bypasses orbital positions entirely, performs a display-only size layout, and uses neutral presentation lighting rather than the orbital Sun light. +Modes choose the provider and layout intent. `toScenePosition` is the boundary where physical AU positions become view coordinates. Distance scale and body radius scale are independent controls with separate calculations: true-distance ratios can coexist with either readable exaggerated bodies or an orbit-safe compact presentation. Selecting true distance chooses compact bodies by default, while the explicit body-size control keeps the exaggerated option available. True-distance mode retains the Moon's physical parent-relative vector in simulation state; when enlarged rendered radii would overlap, a visualization-only minimum separation derived from the two display radii and a small gap moves the satellite marker and orbit path outward along that same vector. The lineup bypasses orbital positions entirely, performs a display-only size layout, and uses neutral presentation lighting rather than the orbital Sun light. Orbit paths are a presentation concern with off, subdued unified-colour, and tasteful body-colour states. The Keplerian scene retains true orbital inclination, an angled camera, and a subtle labelled J2000 ecliptic reference plane; vertical geometry is not exaggerated. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2a7a6f5..21e022d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -10,7 +10,7 @@ ## Verification -- `npm test`: 17/17 passing, including element-rate, mean-longitude phase, long-frame-gap, and body-scale policy regressions +- `npm test`: 18/18 passing, including element-rate, mean-longitude phase, long-frame-gap, body-scale policy, and True Distance satellite-separation regressions - `npm run typecheck`: passing - `npm run build`: passing (expected Three.js bundle-size warning) - Browser smoke test: passing for all bidirectional speed presets, direction switching, out-of-range warning, and focus action; no runtime errors @@ -22,7 +22,7 @@ - AU/J2000-style ecliptic simulation data stays separate from scene scaling. - JPL approximate elements are educational, not ephemeris-grade; Moon elements are mean approximations. -- Body radii are exaggerated outside lineup; lineup explicitly applies nonlinear radius compression. Moon orbital display radius is enlarged. +- Body radii are exaggerated outside lineup; lineup explicitly applies nonlinear radius compression. Under True Distance, the Moon retains its physical simulation vector but may receive a visualization-only minimum display separation when enlarged body radii would overlap. - Camera focus eases to a body; persistent follow mode and high-volume instancing are future work. ## Pre-merge review corrections @@ -45,3 +45,9 @@ Final review and hands-on interaction/container testing before squash merge. A s - Orbit display supports Off, subdued Unified colour, and tasteful Body colours. - Visualization-mode changes ease to appropriate camera presets; the Keplerian preset is angled over a subtle labelled J2000 ecliptic reference without changing true inclinations or exaggerating Z. - Verification: 17 tests, strict typecheck, and production build pass; local development HTTP smoke returns 200. Focused visual browser automation remains pending due to unavailable Browser control in this session. + +## Targeted True Distance satellite correction + +- Compact Earth/Moon rendering now derives a minimum visual satellite separation from their rendered radii plus a small gap, consistently for the marker and orbit path. +- The Moon's `localPositionAu` remains the unmodified Keplerian parent-relative orbital result. +- Verification: 18 tests, strict typecheck, and production build pass. diff --git a/src/scene/SolarSystemScene.tsx b/src/scene/SolarSystemScene.tsx index 6f56032..d072452 100644 --- a/src/scene/SolarSystemScene.tsx +++ b/src/scene/SolarSystemScene.tsx @@ -9,7 +9,7 @@ import { circularProvider } from '../orbits/circular' import { keplerianProvider } from '../orbits/keplerian' import { calculateSystemPositions } from '../orbits/system' import { rotationAngleAt } from '../simulation/rotation' -import { sceneRadiusForBody } from '../visualization/bodyScale' +import { satelliteDisplayOffset, sceneRadiusForBody } from '../visualization/bodyScale' import { toScenePosition, type BodySizeScaleId, @@ -49,6 +49,7 @@ const toDisplayPositions = ( positioned: Map, mode: VisualizationModeId, scale: DistanceScaleId, + bodySizeScale: BodySizeScaleId, ): Map => { if (mode === 'lineup') return lineupPositions() const display = new Map() @@ -71,7 +72,17 @@ const toDisplayPositions = ( if (moon && earth) { const local = moon.localPositionAu if (mode === 'keplerian' && scale === 'true-distance') { - display.set('moon', [earth[0] + local.x, earth[1] + local.z, earth[2] + local.y]) + const parent = bodyById.get(moon.body.parentId!)! + const displayOffset = satelliteDisplayOffset( + { x: local.x, y: local.z, z: local.y }, + sceneRadiusForBody(parent, mode, bodySizeScale), + sceneRadiusForBody(moon.body, mode, bodySizeScale), + ) + display.set('moon', [ + earth[0] + displayOffset.x, + earth[1] + displayOffset.y, + earth[2] + displayOffset.z, + ]) return display } const length = Math.hypot(local.x, local.y, local.z) || 1 @@ -206,10 +217,11 @@ const CameraController = ({ selectedId, focusRequest, displayPositions, mode }: ) } -const OrbitPaths = ({ timestampMs, mode, scale, positions, display }: { +const OrbitPaths = ({ timestampMs, mode, scale, bodySizeScale, positions, display }: { timestampMs: number mode: VisualizationModeId scale: DistanceScaleId + bodySizeScale: BodySizeScaleId positions: Map display: Exclude }) => { @@ -223,7 +235,17 @@ const OrbitPaths = ({ timestampMs, mode, scale, positions, display }: { const local = provider.positionAt(body, sampleTimestamp) if (body.category === 'moon') { if (mode === 'keplerian' && scale === 'true-distance') { - points.push([parentPosition[0] + local.x, parentPosition[1] + local.z, parentPosition[2] + local.y]) + const parent = bodyById.get(body.parentId!)! + const displayOffset = satelliteDisplayOffset( + { x: local.x, y: local.z, z: local.y }, + sceneRadiusForBody(parent, mode, bodySizeScale), + sceneRadiusForBody(body, mode, bodySizeScale), + ) + points.push([ + parentPosition[0] + displayOffset.x, + parentPosition[1] + displayOffset.y, + parentPosition[2] + displayOffset.z, + ]) continue } const length = Math.hypot(local.x, local.y, local.z) || 1 @@ -240,7 +262,7 @@ const OrbitPaths = ({ timestampMs, mode, scale, positions, display }: { } } return { id: body.id, points, isMoon: body.category === 'moon', color: body.visual.color } - }), [mode, positions, provider, scale, timestampMs]) + }), [bodySizeScale, mode, positions, provider, scale, timestampMs]) return <>{paths.map((path) => ( { [props.timestampMs, provider], ) const displayPositions = useMemo( - () => toDisplayPositions(calculated, props.mode, props.distanceScale), - [calculated, props.distanceScale, props.mode], + () => toDisplayPositions(calculated, props.mode, props.distanceScale, props.bodySizeScale), + [calculated, props.bodySizeScale, props.distanceScale, props.mode], ) return ( @@ -282,7 +304,7 @@ const SceneContent = (props: SolarSystemSceneProps) => { )} - {props.orbitDisplay !== 'off' && props.mode !== 'lineup' && } + {props.orbitDisplay !== 'off' && props.mode !== 'lineup' && } {CELESTIAL_BODIES.map((body) => ( ))} diff --git a/src/visualization/bodyScale.test.ts b/src/visualization/bodyScale.test.ts index b27eb5e..8b8a1dc 100644 --- a/src/visualization/bodyScale.test.ts +++ b/src/visualization/bodyScale.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' -import { bodyById } from '../data/bodies' -import { sceneRadiusForBody } from './bodyScale' +import { bodyById, CELESTIAL_BODIES } from '../data/bodies' +import { keplerianProvider } from '../orbits/keplerian' +import { calculateSystemPositions } from '../orbits/system' +import { sceneRadiusForBody, MIN_SATELLITE_DISPLAY_GAP, satelliteDisplayOffset } from './bodyScale' describe('body presentation scale', () => { it('keeps body size independent from distance scale', () => { @@ -17,4 +19,28 @@ describe('body presentation scale', () => { expect(compact).toBeLessThan(0.38709927 / 2) expect(readable).toBeGreaterThan(compact * 4) }) + + it('keeps true-distance Moon display spheres separate without changing its orbital vector', () => { + const timestampMs = Date.UTC(2026, 7, 12) + const positions = calculateSystemPositions(CELESTIAL_BODIES, timestampMs, keplerianProvider) + const moon = positions.get('moon')! + const earth = bodyById.get('earth')! + const moonBody = bodyById.get('moon')! + const expectedLocalPosition = keplerianProvider.positionAt(moonBody, timestampMs) + expect(moon.localPositionAu).toEqual(expectedLocalPosition) + + const displayOffset = satelliteDisplayOffset( + { x: moon.localPositionAu.x, y: moon.localPositionAu.z, z: moon.localPositionAu.y }, + sceneRadiusForBody(earth, 'keplerian', 'compact'), + sceneRadiusForBody(moonBody, 'keplerian', 'compact'), + ) + const displayDistance = Math.hypot(displayOffset.x, displayOffset.y, displayOffset.z) + const requiredDistance = + sceneRadiusForBody(earth, 'keplerian', 'compact') + + sceneRadiusForBody(moonBody, 'keplerian', 'compact') + + MIN_SATELLITE_DISPLAY_GAP + + expect(displayDistance).toBeGreaterThanOrEqual(requiredDistance) + expect(Math.hypot(moon.localPositionAu.x, moon.localPositionAu.y, moon.localPositionAu.z)).toBeLessThan(requiredDistance) + }) }) diff --git a/src/visualization/bodyScale.ts b/src/visualization/bodyScale.ts index 2f72b91..20710ef 100644 --- a/src/visualization/bodyScale.ts +++ b/src/visualization/bodyScale.ts @@ -1,6 +1,8 @@ -import type { CelestialBody } from '../domain/celestial' +import type { CelestialBody, Vector3Au } from '../domain/celestial' import type { BodySizeScaleId, VisualizationModeId } from './modes' +export const MIN_SATELLITE_DISPLAY_GAP = 0.012 + export const sceneRadiusForBody = ( body: CelestialBody, mode: VisualizationModeId, @@ -19,3 +21,26 @@ export const sceneRadiusForBody = ( if (body.category === 'moon') return 0.105 return Math.max(0.13, Math.pow(body.radiusKm / 6_371, 0.38) * 0.2) } + +export const satelliteDisplayOffset = ( + localSceneOffset: Vector3Au, + parentRadius: number, + satelliteRadius: number, +): Vector3Au => { + const physicalDistance = Math.hypot( + localSceneOffset.x, + localSceneOffset.y, + localSceneOffset.z, + ) + const minimumDistance = parentRadius + satelliteRadius + MIN_SATELLITE_DISPLAY_GAP + if (physicalDistance >= minimumDistance) return localSceneOffset + + const direction = physicalDistance > 0 + ? 1 / physicalDistance + : 1 + return { + x: localSceneOffset.x * direction * minimumDistance || minimumDistance, + y: localSceneOffset.y * direction * minimumDistance, + z: localSceneOffset.z * direction * minimumDistance, + } +} From 73100db3376cbf900e60d5b77370466c8cb39e39 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Wed, 12 Aug 2026 08:48:55 -0700 Subject: [PATCH 7/7] Add Pluto and clarify active models --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/HANDOFF.md | 10 +++++++++- docs/PROJECT.md | 2 +- src/App.tsx | 33 ++++++++++++++++++++++++++++++++ src/data/bodies.test.ts | 22 +++++++++++++++++++++ src/data/bodies.ts | 16 ++++++++++++++++ src/scene/SolarSystemScene.tsx | 19 ++---------------- src/styles.css | 7 +++++-- src/visualization/lineup.test.ts | 19 ++++++++++++++++++ src/visualization/lineup.ts | 21 ++++++++++++++++++++ 11 files changed, 130 insertions(+), 23 deletions(-) create mode 100644 src/data/bodies.test.ts create mode 100644 src/visualization/lineup.test.ts create mode 100644 src/visualization/lineup.ts diff --git a/README.md b/README.md index 8b2db26..db69777 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SolarSystem -An interactive, browser-based 3D Solar System explorer. The initial milestone includes the Sun, eight planets, Earth's Moon, deterministic time controls, selection, camera focus, and three complementary views. +An interactive, browser-based 3D Solar System explorer. The initial milestone includes the Sun, eight planets, Pluto, Earth's Moon, deterministic time controls, selection, camera focus, and three complementary views. The 3D view is an **educational Keplerian model**, not an authoritative ephemeris. See [project scope](docs/PROJECT.md) and [architecture](docs/ARCHITECTURE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52934b7..5b0671e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,6 @@ Orbit paths are a presentation concern with off, subdued unified-colour, and tas ## Data sources and limits -Planetary orbital elements and rates use JPL Solar System Dynamics, [Approximate Positions of the Planets](https://ssd.jpl.nasa.gov/planets/approx_pos.html), Table 1 (1800–2050). The UI warns, but does not block exploration, when the Keplerian date is outside that fitted interval. Physical radii, rotation periods, and tilts are rounded from NASA/NSSDC planetary fact sheets. The Moon uses standard educational mean elements. Two-body elements omit perturbations and should never be presented as observation-grade coordinates. +The eight major planets use JPL Solar System Dynamics, [Approximate Positions of the Planets](https://ssd.jpl.nasa.gov/planets/approx_pos.html), Table 1 (1800–2050), including fitted rates. The UI warns, but does not block exploration, when the Keplerian date is outside that fitted interval. Pluto uses NASA/NSSDC's [Pluto Fact Sheet](https://nssdc.gsfc.nasa.gov/planetary/factsheet/plutofact.html) fixed J2000 mean elements (a≈39.4817 AU, e≈0.2488, i≈17.14°), with period-based propagation; it is an educational approximation rather than the fitted-rate planetary model. Physical radii, rotation periods, and tilts are rounded from NASA/NSSDC planetary fact sheets. The Moon uses standard educational mean elements. Two-body elements omit perturbations and should never be presented as observation-grade coordinates. The visual layer updates only this small initial body set through React today. If catalogs reach hundreds or thousands of objects, migrate high-frequency body matrices to instancing and mutable render-loop buffers without changing the provider/domain boundaries. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 21e022d..bcf38fe 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -6,7 +6,7 @@ - Initial implementation commit: `4df27b2` - Latest visual-polish commit: `653436a` - Review: [PR #1](https://github.com/kevinatlee/SolarSystem/pull/1) (open, intentionally unmerged) -- Initial vertical slice implemented: typed body catalog, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. +- Initial vertical slice implemented: typed body catalog including Pluto, circular and Keplerian providers, generic Earth/Moon hierarchy, deterministic clock/rotation, three visual modes, selection/details/focus, scale/orbit controls, responsive UI, tests, and Docker/nginx deployment. ## Verification @@ -51,3 +51,11 @@ Final review and hands-on interaction/container testing before squash merge. A s - Compact Earth/Moon rendering now derives a minimum visual satellite separation from their rendered radii plus a small gap, consistently for the marker and orbit path. - The Moon's `localPositionAu` remains the unmodified Keplerian parent-relative orbital result. - Verification: 18 tests, strict typecheck, and production build pass. + +## Pluto and hands-on transparency update + +- Pluto is a selectable dwarf planet with a Sun-parented fixed J2000 educational orbit, rotation, label, facts, and all three visualization-mode representations. +- Pluto intentionally has no JPL fitted orbital rates; its source and period-based propagation are documented separately from the eight-planet 1800–2050 model. +- Size Lineup now places the Moon next to Earth with radius-derived clearance. +- Active Model dynamically lists the active mode's faithful geometry, readability adjustments, and JPL/Pluto data limitations. +- Verification: 20 tests, strict typecheck, and production build pass. diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 09143db..e122ae5 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -4,7 +4,7 @@ SolarSystem makes the scale and motion of our planetary system approachable thro ## Current scope -- Sun, eight planets, and Earth's Moon +- Sun, eight planets, Pluto, and Earth's Moon - current-time initialization and arbitrary UTC date/time selection - play, pause, forward, reverse, speed presets, and reset to now - selectable bodies, educational facts, coordinates, and camera focus diff --git a/src/App.tsx b/src/App.tsx index 53c3836..4a5e1a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,35 @@ import { type VisualizationModeId, } from './visualization/modes' +const modelTransparency = ( + mode: VisualizationModeId, + distanceScale: DistanceScaleId, + bodySizeScale: BodySizeScaleId, +): readonly string[] => { + if (mode === 'simplified') { + return [ + 'Circular, coplanar orbital paths.', + 'Orbital spacing and body sizes are compressed for readability.', + 'Moon orbit is enlarged for visibility.', + 'Pluto uses fixed J2000 educational elements.', + ] + } + if (mode === 'lineup') { + return [ + 'Orbital locations and distances are intentionally ignored.', + 'Bodies are side-by-side with nonlinear size compression.', + 'Moon is placed beside Earth for comparison.', + ] + } + return [ + 'Keplerian eccentricities and inclinations are shown at true geometry.', + distanceScale === 'true-distance' ? 'Orbital-distance ratios are preserved.' : 'Displayed orbital spacing is compressed.', + bodySizeScale === 'compact' ? 'Compact bodies are enlarged but orbit-safe.' : 'Readable body sizes are intentionally enlarged.', + distanceScale === 'true-distance' ? 'Moon display separation may expand to clear enlarged spheres.' : 'Moon orbit is enlarged for visibility.', + 'Eight planets use JPL fitted elements (1800–2050); Pluto uses fixed J2000 elements.', + ] +} + export const App = () => { const clock = useSimulationClock() const [mode, setMode] = useState('simplified') @@ -24,6 +53,7 @@ export const App = () => { const [selectedId, setSelectedId] = useState('earth') const [focusRequest, setFocusRequest] = useState(0) const activeMode = VISUALIZATION_MODES.find((item) => item.id === mode)! + const transparencyNotes = modelTransparency(mode, distanceScale, bodySizeScale) const positions = useMemo( () => calculateSystemPositions(CELESTIAL_BODIES, clock.timestampMs, mode === 'keplerian' ? keplerianProvider : circularProvider), [clock.timestampMs, mode], @@ -83,6 +113,9 @@ export const App = () => { Active model {activeMode.label} {activeMode.description} +
    + {transparencyNotes.map((note) =>
  • {note}
  • )} +
{isOutsideKeplerianRange && (
diff --git a/src/data/bodies.test.ts b/src/data/bodies.test.ts new file mode 100644 index 0000000..677b707 --- /dev/null +++ b/src/data/bodies.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { CELESTIAL_BODIES, bodyById } from './bodies' +import { keplerianPositionAtJulianDate } from '../orbits/keplerian' + +describe('celestial body catalog', () => { + it('represents Pluto as a fixed-element dwarf planet orbiting the Sun', () => { + const pluto = bodyById.get('pluto')! + expect(CELESTIAL_BODIES).toContain(pluto) + expect(pluto.category).toBe('dwarf-planet') + expect(pluto.parentId).toBe('sun') + expect(pluto.orbit?.ratesPerJulianCentury).toBeUndefined() + + const start = keplerianPositionAtJulianDate(pluto.orbit!, pluto.orbit!.epochJulianDate) + const repeated = keplerianPositionAtJulianDate( + pluto.orbit!, + pluto.orbit!.epochJulianDate + pluto.orbit!.orbitalPeriodDays, + ) + expect(repeated.x).toBeCloseTo(start.x, 10) + expect(repeated.y).toBeCloseTo(start.y, 10) + expect(repeated.z).toBeCloseTo(start.z, 10) + }) +}) diff --git a/src/data/bodies.ts b/src/data/bodies.ts index e47a5ff..a921dfd 100644 --- a/src/data/bodies.ts +++ b/src/data/bodies.ts @@ -109,6 +109,22 @@ export const CELESTIAL_BODIES: readonly CelestialBody[] = [ simplifiedOrbitRadius: 16.0, visual: { color: '#3159cf', accent: '#719aff' }, description: 'The outermost major planet, an ice giant with supersonic winds and dark storms.', }, + { + // NASA/NSSDC's J2000 mean elements: fixed educational elements, not the + // JPL 1800–2050 fitted-rate model used for the eight major planets. + id: 'pluto', name: 'Pluto', category: 'dwarf-planet', parentId: 'sun', radiusKm: 1_188.3, + massKg: 1.303e22, siderealRotationPeriodHours: -153.2928, axialTiltRad: degreesToRadians(119.6), + orbit: { + semiMajorAxisAu: 39.48168677, eccentricity: 0.24880766, + inclinationRad: degreesToRadians(17.14175), + longitudeAscendingNodeRad: degreesToRadians(110.30347), + argumentPeriapsisRad: degreesToRadians(113.76329), + meanAnomalyAtEpochRad: degreesToRadians(14.86205), + epochJulianDate: J2000_JULIAN_DATE, orbitalPeriodDays: 90_560, + }, + simplifiedOrbitRadius: 18.3, visual: { color: '#b48d79', accent: '#eed0b7' }, + description: 'A dwarf planet in the Kuiper Belt with a highly inclined, eccentric orbit and a binary-like relationship with Charon.', + }, ] as const export const bodyById = new Map(CELESTIAL_BODIES.map((body) => [body.id, body])) diff --git a/src/scene/SolarSystemScene.tsx b/src/scene/SolarSystemScene.tsx index d072452..046e815 100644 --- a/src/scene/SolarSystemScene.tsx +++ b/src/scene/SolarSystemScene.tsx @@ -10,6 +10,7 @@ import { keplerianProvider } from '../orbits/keplerian' import { calculateSystemPositions } from '../orbits/system' import { rotationAngleAt } from '../simulation/rotation' import { satelliteDisplayOffset, sceneRadiusForBody } from '../visualization/bodyScale' +import { createLineupPositions } from '../visualization/lineup' import { toScenePosition, type BodySizeScaleId, @@ -29,29 +30,13 @@ interface SolarSystemSceneProps { onSelect: (id: string) => void } -const planetIds = CELESTIAL_BODIES.filter((body) => body.category !== 'moon').map((body) => body.id) - -const lineupPositions = (): Map => { - const positions = new Map() - let cursor = -14 - planetIds.forEach((id) => { - const body = bodyById.get(id)! - const radius = sceneRadiusForBody(body, 'lineup', 'readable') - cursor += radius - positions.set(id, [cursor, 0, 0]) - cursor += radius + (id === 'sun' ? 2.2 : 0.8) - }) - positions.set('moon', [15.6, -0.25, 0]) - return positions -} - const toDisplayPositions = ( positioned: Map, mode: VisualizationModeId, scale: DistanceScaleId, bodySizeScale: BodySizeScaleId, ): Map => { - if (mode === 'lineup') return lineupPositions() + if (mode === 'lineup') return createLineupPositions() const display = new Map() positioned.forEach((entry) => { if (entry.body.category === 'moon') return diff --git a/src/styles.css b/src/styles.css index 9c33fd7..34743a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -49,9 +49,12 @@ select:focus, input:focus { border-color: #5bb8d2; } .panel { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 18px 50px rgba(0, 0, 0, .32); backdrop-filter: blur(16px); } .eyebrow { font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .17em; text-transform: uppercase; color: #6f839a; } -.mode-card { position: absolute; left: 25px; top: 98px; z-index: 5; width: 224px; padding: 16px; border-radius: 9px; display: flex; flex-direction: column; gap: 5px; } +.mode-card { position: absolute; left: 25px; top: 98px; z-index: 5; width: 282px; padding: 16px; border-radius: 9px; display: flex; flex-direction: column; gap: 5px; } .mode-card strong { font-size: 13px; } .mode-card small { color: var(--muted); font-size: 10px; line-height: 1.5; } +.model-transparency { margin: 8px 0 0; padding: 9px 0 0 15px; border-top: 1px solid var(--line); color: #8fa2b7; font-size: 9px; line-height: 1.5; } +.model-transparency li + li { margin-top: 3px; } +.model-transparency li::marker { color: #6bbbd1; } .validity-warning { position: absolute; left: 25px; top: 207px; z-index: 5; width: 290px; padding: 11px 13px; border-color: rgba(244, 187, 91, .35); border-radius: 8px; color: #e6c98e; font-size: 10px; line-height: 1.5; } .ecliptic-key { position: absolute; left: 50%; top: 96px; z-index: 4; transform: translateX(-50%); display: flex; align-items: center; gap: 7px; color: #60748a; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } .ecliptic-key span { width: 24px; height: 1px; background: #315779; box-shadow: 0 0 8px rgba(79, 130, 172, .35); } @@ -97,7 +100,7 @@ select:focus, input:focus { border-color: #5bb8d2; } .view-actions { position: absolute; top: 83px; right: 12px; padding: 8px; background: rgba(4, 10, 20, .84); border: 1px solid var(--line); border-radius: 9px; backdrop-filter: blur(14px); } .brand small, .mode-tabs button small { display: none; } .info-panel { right: 12px; top: 132px; width: 244px; max-height: calc(100vh - 280px); } - .mode-card { left: 12px; top: 132px; width: 190px; } + .mode-card { left: 12px; top: 132px; width: 244px; } .ecliptic-key { top: 154px; } .validity-warning { left: 12px; top: 241px; width: 244px; } .time-controls { left: 12px; bottom: 12px; width: min(348px, calc(100vw - 24px)); } diff --git a/src/visualization/lineup.test.ts b/src/visualization/lineup.test.ts new file mode 100644 index 0000000..778ca33 --- /dev/null +++ b/src/visualization/lineup.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { bodyById } from '../data/bodies' +import { sceneRadiusForBody } from './bodyScale' +import { createLineupPositions } from './lineup' + +describe('size lineup layout', () => { + it('places the Moon directly beside Earth with visible clearance', () => { + const positions = createLineupPositions() + const earthX = positions.get('earth')![0] + const moonX = positions.get('moon')![0] + const marsX = positions.get('mars')![0] + const earthRadius = sceneRadiusForBody(bodyById.get('earth')!, 'lineup', 'readable') + const moonRadius = sceneRadiusForBody(bodyById.get('moon')!, 'lineup', 'readable') + + expect(moonX).toBeGreaterThan(earthX) + expect(moonX).toBeLessThan(marsX) + expect(moonX - earthX).toBeGreaterThan(earthRadius + moonRadius) + }) +}) diff --git a/src/visualization/lineup.ts b/src/visualization/lineup.ts new file mode 100644 index 0000000..a08c592 --- /dev/null +++ b/src/visualization/lineup.ts @@ -0,0 +1,21 @@ +import { CELESTIAL_BODIES, bodyById } from '../data/bodies' +import { sceneRadiusForBody } from './bodyScale' + +const LINEUP_GAP = 0.8 + +const lineupBodyIds = CELESTIAL_BODIES.flatMap((body) => + body.id === 'earth' ? ['earth', 'moon'] : body.category === 'moon' ? [] : [body.id], +) + +export const createLineupPositions = (): Map => { + const positions = new Map() + let cursor = -14 + lineupBodyIds.forEach((id) => { + const body = bodyById.get(id)! + const radius = sceneRadiusForBody(body, 'lineup', 'readable') + cursor += radius + positions.set(id, [cursor, 0, 0]) + cursor += radius + LINEUP_GAP + }) + return positions +}