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..db69777 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, 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). + +## 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..5b0671e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,34 @@ +# 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. 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 + +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. + +## Data sources and limits + +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 new file mode 100644 index 0000000..bcf38fe --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,61 @@ +# Handoff + +## State + +- 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 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 + +- `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 +- 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 + +## 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. 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 + +- 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 + +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. + +## 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. + +## 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 new file mode 100644 index 0000000..e122ae5 --- /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, 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 +- 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..4a5e1a6 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,137 @@ +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 BodySizeScaleId, + type DistanceScaleId, + type OrbitDisplayId, + 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') + 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 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], + ) + const selected = selectedId ? positions.get(selectedId) : undefined + const simulationYear = new Date(clock.timestampMs).getUTCFullYear() + const isOutsideKeplerianRange = + mode === 'keplerian' && (simulationYear < 1800 || simulationYear > 2050) + + return ( +
+
+
SolarSystemOrbital explorer
+ +
+ {mode !== 'lineup' && ( + <> + + + + + )} +
+
+
+ +
+ Active model + {activeMode.label} + {activeMode.description} +
    + {transparencyNotes.map((note) =>
  • {note}
  • )} +
+
+ {isOutsideKeplerianRange && ( +
+ 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)} />} +
+
Educational model · Approximate positions, not an authoritative ephemeris
+
+ ) +} 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 new file mode 100644 index 0000000..a921dfd --- /dev/null +++ b/src/data/bodies.ts @@ -0,0 +1,130 @@ +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.', + }, + { + // 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/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..112cb78 --- /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' +import { propagateOrbitalElements } from './propagation' + +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 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 new file mode 100644 index 0000000..a563a6a --- /dev/null +++ b/src/orbits/keplerian.ts @@ -0,0 +1,68 @@ +import type { CelestialBody, OrbitalElements, Vector3Au } from '../domain/celestial' +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 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') +} + +export const keplerianPositionAtJulianDate = ( + sourceElements: OrbitalElements, + julianDate: number, +): Vector3Au => { + const elements = propagateOrbitalElements(sourceElements, julianDate) + const eccentricAnomaly = solveEccentricAnomaly(elements.meanAnomalyRad, 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..5888df1 --- /dev/null +++ b/src/orbits/orbits.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { CelestialBody, OrbitalElements } from '../domain/celestial' +import { J2000_JULIAN_DATE, julianDateToTimestamp } from '../math/julian' +import { circularPosition, circularProvider } 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('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) + }) + + 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/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/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..046e815 --- /dev/null +++ b/src/scene/SolarSystemScene.tsx @@ -0,0 +1,316 @@ +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 { 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 { satelliteDisplayOffset, sceneRadiusForBody } from '../visualization/bodyScale' +import { createLineupPositions } from '../visualization/lineup' +import { + toScenePosition, + type BodySizeScaleId, + type DistanceScaleId, + type OrbitDisplayId, + type VisualizationModeId, +} from '../visualization/modes' + +interface SolarSystemSceneProps { + timestampMs: number + mode: VisualizationModeId + distanceScale: DistanceScaleId + bodySizeScale: BodySizeScaleId + selectedId: string | null + focusRequest: number + orbitDisplay: OrbitDisplayId + onSelect: (id: string) => void +} + +const toDisplayPositions = ( + positioned: Map, + mode: VisualizationModeId, + scale: DistanceScaleId, + bodySizeScale: BodySizeScaleId, +): Map => { + if (mode === 'lineup') return createLineupPositions() + 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 + if (mode === 'keplerian' && scale === 'true-distance') { + 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 + 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 + bodySizeScale: BodySizeScaleId + selected: boolean + onSelect: (id: string) => void +} + +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 = sceneRadiusForBody(body, mode, bodySizeScale) + + 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()) + 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 + 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) + isFocusing.current = true + }, [displayPositions, focusRequest, mode, selectedId]) + + useFrame(() => { + if (!controls.current || !isFocusing.current) return + controls.current.target.lerp(target.current, 0.075) + 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 ( + { isFocusing.current = false }} + /> + ) +} + +const OrbitPaths = ({ timestampMs, mode, scale, bodySizeScale, positions, display }: { + timestampMs: number + mode: VisualizationModeId + scale: DistanceScaleId + bodySizeScale: BodySizeScaleId + positions: Map + display: Exclude +}) => { + 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') { + if (mode === 'keplerian' && scale === 'true-distance') { + 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 + 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', color: body.visual.color } + }), [bodySizeScale, 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, props.bodySizeScale), + [calculated, props.bodySizeScale, props.distanceScale, props.mode], + ) + + return ( + <> + + + {props.mode === 'lineup' ? ( + <> + + + + + ) : ( + <> + + + + )} + + {props.orbitDisplay !== 'off' && props.mode !== 'lineup' && } + {CELESTIAL_BODIES.map((body) => ( + + ))} + + {props.mode === 'keplerian' && ( + <> + + + + + + + + + )} + + ) +} + +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..b4be104 --- /dev/null +++ b/src/simulation/clock.test.ts @@ -0,0 +1,19 @@ +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) + }) + 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/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..b1c35ed --- /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 = time - lastFrame.current + 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..34743a2 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,123 @@ +: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; 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; } + +.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: 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); } +.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; 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: 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)); } + .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 { 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/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..e84523b --- /dev/null +++ b/src/ui/TimeControls.tsx @@ -0,0 +1,54 @@ +import type { SimulationClockController } from '../simulation/useSimulationClock' + +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 + 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/bodyScale.test.ts b/src/visualization/bodyScale.test.ts new file mode 100644 index 0000000..8b8a1dc --- /dev/null +++ b/src/visualization/bodyScale.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +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', () => { + 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) + }) + + 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 new file mode 100644 index 0000000..20710ef --- /dev/null +++ b/src/visualization/bodyScale.ts @@ -0,0 +1,46 @@ +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, + 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) +} + +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, + } +} 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 +} diff --git a/src/visualization/modes.ts b/src/visualization/modes.ts new file mode 100644 index 0000000..9c26ce0 --- /dev/null +++ b/src/visualization/modes.ts @@ -0,0 +1,32 @@ +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 + 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'] }, + }, +})