diff --git a/README.md b/README.md
index aef7e02..8dac4a8 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,18 @@
-# The TYCHOSIUM 💫
+# The Tychosium 💫




-**The TYCHOSIUM** is an interactive 3D astronomical simulation implementing the **TYCHOS model** of our solar system. It offers a unique perspective on celestial mechanics, featuring real-time orbital calculations, a comprehensive star catalog, and immersive visualization.
+**The Tychosium** is an interactive 3D astronomical simulation implementing the **TYCHOS model** of our solar system. It offers a unique perspective on celestial mechanics, featuring real-time orbital calculations, a comprehensive star catalog, and immersive visualization.
Built with modern web technologies to ensure performance and accuracy, this project aims to visualize the binary solar system concepts proposed by the Tychos model.
---
## 📑 Table of Contents
+
- [Features](#-features)
- [The TYCHOS Model](#-the-tychos-model)
- [Technical Stack](#-technical-stack)
@@ -27,31 +28,35 @@ Built with modern web technologies to ensure performance and accuracy, this proj
## 🌟 Features
### 🔭 Astronomical Simulation
-* **TYCHOS Implementation:** Full realization of the Earth-centered binary system model.
-* **Real-time Mechanics:** Accurate planetary motions with the ability to traverse time (past/future).
-* **Celestial Bodies:** Includes the Sun, Moon, planets, major asteroids, and Halley's comet.
-* **Star Catalog:** Integrated **Bright Star Catalog (BSC)** containing over 9,000 stars with accurate magnitude and color data.
+
+- **TYCHOS Implementation:** Full realization of the Earth-centered binary system model.
+- **Real-time Mechanics:** Accurate planetary motions with the ability to traverse time (past/future).
+- **Celestial Bodies:** Includes the Sun, Moon, planets, major asteroids, and Halley's comet.
+- **Star Catalog:** Integrated **Bright Star Catalog (BSC)** containing over 9,000 stars with accurate magnitude and color data.
### 🎨 3D Visualization
-* **Dual Camera System:** Switch between a global "Orbit Camera" and a surface-level "Planet Camera".
-* **Orbital Tracing:** Visualize complex planetary geometric paths (spirographs) over time.
-* **Visual Aids:** Ecliptic grids, celestial sphere, zodiacal bands, and polar lines.
-* **High-Fidelity Graphics:** Realistic textures and dynamic solar lighting using post-processing effects.
+
+- **Dual Camera System:** Switch between a global "Orbit Camera" and a surface-level "Planet Camera".
+- **Orbital Tracing:** Visualize complex planetary geometric paths (spirographs) over time.
+- **Visual Aids:** Ecliptic grids, celestial sphere, zodiacal bands, and polar lines.
+- **High-Fidelity Graphics:** Realistic textures and dynamic solar lighting using post-processing effects.
### 🎛️ Advanced Interaction
-* **Time Travel:** Jump to specific historical or future dates instantly.
-* **Perpetual calendar:** Gregorian dates and support for Astronomical Julian day.
-* **Variable Speed:** Control simulation speed from real-time up to millennial steps.
-* **Smart Search:** Search implementation to quickly locate stars by name/HR number.
+
+- **Time Travel:** Jump to specific historical or future dates instantly.
+- **Perpetual calendar:** Gregorian dates and support for Astronomical Julian day.
+- **Variable Speed:** Control simulation speed from real-time up to millennial steps.
+- **Smart Search:** Search implementation to quickly locate stars by name/HR number.
---
## 💫 The TYCHOS Model
This simulation is distinct from standard heliocentric visualizers. It implements the TYCHOS model, which proposes:
-* **Earth as Reference:** Earth remains relatively stationary at the center of the system.
-* **Binary System:** The Sun and Mars are binary companions.
-* **PVP Orbit:** The entire solar system rotates together with Earth in the PVP-orbit (Polaris-Vega-Polaris).
+
+- **Earth as Reference:** Earth remains relatively stationary at the center of the system.
+- **Binary System:** The Sun and Mars are binary companions.
+- **PVP Orbit:** The entire solar system rotates together with Earth in the PVP-orbit (Polaris-Vega-Polaris).
> 📖 **Learn more:** [tychos.space](https://www.tychos.space)
@@ -61,33 +66,36 @@ This simulation is distinct from standard heliocentric visualizers. It implement
This project leverages the latest ecosystem for 3D web development.
-| Category | Technology | Purpose |
-|----------|------------|---------|
-| **Core** | [React 18](https://reactjs.org/) | UI and Component Architecture |
-| **3D Engine** | [Three.js](https://threejs.org/) | WebGL Rendering Engine |
-| **Renderer** | [React Three Fiber](https://docs.pmnd.rs/react-three-fiber/) | React renderer for Three.js |
-| **Helpers** | [@react-three/drei](https://github.com/pmndrs/drei) | High-level 3D abstractions |
-| **Effects** | [Postprocessing](https://github.com/pmndrs/postprocessing) | Bloom, glow, and visual effects |
-| **State** | [Zustand](https://github.com/pmndrs/zustand) | Global state management |
-| **GUI** | [Leva](https://github.com/pmndrs/leva) | Tweakable control panels |
+| Category | Technology | Purpose |
+| ------------- | ------------------------------------------------------------ | ------------------------------- |
+| **Core** | [React 18](https://reactjs.org/) | UI and Component Architecture |
+| **3D Engine** | [Three.js](https://threejs.org/) | WebGL Rendering Engine |
+| **Renderer** | [React Three Fiber](https://docs.pmnd.rs/react-three-fiber/) | React renderer for Three.js |
+| **Helpers** | [@react-three/drei](https://github.com/pmndrs/drei) | High-level 3D abstractions |
+| **Effects** | [Postprocessing](https://github.com/pmndrs/postprocessing) | Bloom, glow, and visual effects |
+| **State** | [Zustand](https://github.com/pmndrs/zustand) | Global state management |
+| **GUI** | [Leva](https://github.com/pmndrs/leva) | Tweakable control panels |
---
## 🌍 Getting Started
### Prerequisites
-* **Node.js**: v16.0.0 or higher
-* **Package Manager**: `npm` or `yarn`
+
+- **Node.js**: v16.0.0 or higher
+- **Package Manager**: `npm` or `yarn`
### Installation
1. **Clone the repository**
+
```bash
git clone [https://github.com/pholmq/TSN.git](https://github.com/pholmq/TSN.git)
cd TSN
```
2. **Install dependencies**
+
```bash
npm install
```
@@ -99,14 +107,14 @@ This project leverages the latest ecosystem for 3D web development.
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
### Building for Production
+
To create an optimized build for deployment:
+
```bash
npm run build
```
-
-
---
## 📂 Project Structure
@@ -139,15 +147,15 @@ src/
You can customize the simulation logic by editing files in the `src/settings/` directory:
-* **`celestial-settings.json`**: Modify orbital speeds, distances, sizes, and starting positions for planets.
-* **`star-settings.json`**: Adjust the rendering scale, brightness, and colors of stars.
-* **`BSC.json`**: The raw data for the stars.
+- **`celestial-settings.json`**: Modify orbital speeds, distances, sizes, and starting positions for planets.
+- **`star-settings.json`**: Adjust the rendering scale, brightness, and colors of stars.
+- **`BSC.json`**: The raw data for the stars.
---
## 🤝 Contributing
-Contributions are welcome!
+Contributions are welcome!
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
@@ -165,13 +173,13 @@ This project is licensed under the **GNU General Public License v2.0** - see the
## 🙏 Acknowledgments
-* **Simon Shack** for the creation of the TYCHOS model.
-* **Astronomers and scientists throughout the ages** for trying to figure out the mysteries of our world. In particular Tycho Brahe.
-* **Yale University Observatory** for the Bright Star Catalog.
-* **The open-source community** behind **React Three Fiber** and so many other useful things.
+- **Simon Shack** for the creation of the TYCHOS model.
+- **Astronomers and scientists throughout the ages** for trying to figure out the mysteries of our world. In particular Tycho Brahe.
+- **Yale University Observatory** for the Bright Star Catalog.
+- **The open-source community** behind **React Three Fiber** and so many other useful things.
---
## 🎁 Donations
-You can donate to this work by visiting [tychos.space](http://www.tychos.space) and selecting "Donate". Your gift is much appreciated since Simon Shack has devoted a decade on the Tychos research and since we currently receive no funds of any kind for this work.
\ No newline at end of file
+You can donate to this work by visiting [tychos.space](http://www.tychos.space) and selecting "Donate". Your gift is much appreciated since Simon Shack has devoted a decade on the Tychos research and since we currently receive no funds of any kind for this work.
diff --git a/package-lock.json b/package-lock.json
index 33ece5d..61167b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,22 +1,27 @@
{
"name": "the_tychosium",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "the_tychosium",
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "GPL-2.0-or-only",
"dependencies": {
"@react-three/drei": "9.102.6",
"@react-three/fiber": "8.15.19",
"@react-three/postprocessing": "2.19.0",
"@types/three": "0.171.0",
+ "file-saver": "^2.0.5",
"fuse.js": "^7.1.0",
"leva": "0.9.34",
"lodash-es": "^4.17.21",
+ "mediabunny": "^1.37.0",
+ "mobx": "^6.15.0",
+ "mobx-react": "^9.2.1",
"postprocessing": "6.35.2",
+ "r3f-perf": "^7.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "5.4.0",
@@ -3737,6 +3742,18 @@
"integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==",
"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/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3922,6 +3939,15 @@
"@babel/runtime": "^7.13.10"
}
},
+ "node_modules/@radix-ui/react-icons": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
+ "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/@radix-ui/react-presence": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.1.tgz",
@@ -4706,6 +4732,21 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/dom-mediacapture-transform": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
+ "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/dom-webcodecs": "*"
+ }
+ },
+ "node_modules/@types/dom-webcodecs": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
+ "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
+ "license": "MIT"
+ },
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
@@ -5467,6 +5508,23 @@
"react": ">= 16.8.0"
}
},
+ "node_modules/@utsubo/events": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/@utsubo/events/-/events-0.1.7.tgz",
+ "integrity": "sha512-WB/GEj/0h27Bz8rJ0+CBtNz5mLT79ne1OjB7PUM4n0qLBqEDwm6yBzZC3j6tasHjlBPJDYZiBVIA1glaMlgZ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^4.0.7"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -10712,6 +10770,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
+ "license": "MIT"
+ },
"node_modules/file-selector": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz",
@@ -11730,6 +11794,12 @@
"he": "bin/he"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.15",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
+ "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
+ "license": "Apache-2.0"
+ },
"node_modules/hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -12161,6 +12231,12 @@
"node": ">= 4"
}
},
+ "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/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -12708,6 +12784,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"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/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -14763,6 +14845,15 @@
"node": ">= 0.8.0"
}
},
+ "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/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -15254,6 +15345,23 @@
"node": ">= 0.6"
}
},
+ "node_modules/mediabunny": {
+ "version": "1.37.0",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.37.0.tgz",
+ "integrity": "sha512-eV7M9IJ29pr/8RNL1sYtIxNbdMfDMN1hMwMaOFfNLhwuKKGSC+eKwiJFpdVjEJ3zrMA4LGerF4Hps0SENFSAlg==",
+ "license": "MPL-2.0",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@types/dom-mediacapture-transform": "^0.1.11",
+ "@types/dom-webcodecs": "0.1.13"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ }
+ },
"node_modules/memfs": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz",
@@ -16008,6 +16116,66 @@
"node": ">=10"
}
},
+ "node_modules/mobx": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz",
+ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mobx"
+ }
+ },
+ "node_modules/mobx-react": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-9.2.1.tgz",
+ "integrity": "sha512-WJNNm0FB2n0Z0u+jS1QHmmWyV8l2WiAj8V8I/96kbUEN2YbYCoKW+hbbqKKRUBqElu0llxM7nWKehvRIkhBVJw==",
+ "license": "MIT",
+ "dependencies": {
+ "mobx-react-lite": "^4.1.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mobx"
+ },
+ "peerDependencies": {
+ "mobx": "^6.9.0",
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mobx-react-lite": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz",
+ "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mobx"
+ },
+ "peerDependencies": {
+ "mobx": "^6.9.0",
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -18449,6 +18617,16 @@
"node": ">=10"
}
},
+ "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/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -18609,6 +18787,243 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/r3f-perf": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/r3f-perf/-/r3f-perf-7.2.3.tgz",
+ "integrity": "sha512-4+P/N/bnO9D8nzdm3suL/NjPZK/HHdjwpvajhi8j7eB41i2ECN6lX9RXiKSpHzpsDi2ui1tBj6q7/sz5opoqXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-icons": "^1.3.0",
+ "@react-three/drei": "^9.103.0",
+ "@stitches/react": "^1.2.8",
+ "@utsubo/events": "^0.1.7",
+ "zustand": "~4.5.2"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": ">=8.0",
+ "react": ">=18.0",
+ "react-dom": ">=18.0",
+ "three": ">=0.133"
+ },
+ "peerDependenciesMeta": {
+ "@react-three/fiber": {
+ "optional": true
+ },
+ "dom": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/r3f-perf/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/r3f-perf/node_modules/@react-spring/animated": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
+ "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/r3f-perf/node_modules/@react-spring/core": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
+ "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-spring/donate"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/r3f-perf/node_modules/@react-spring/rafz": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
+ "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
+ "license": "MIT"
+ },
+ "node_modules/r3f-perf/node_modules/@react-spring/shared": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
+ "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/rafz": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/r3f-perf/node_modules/@react-spring/three": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz",
+ "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/core": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": ">=6.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "three": ">=0.126"
+ }
+ },
+ "node_modules/r3f-perf/node_modules/@react-spring/types": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
+ "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
+ "license": "MIT"
+ },
+ "node_modules/r3f-perf/node_modules/@react-three/drei": {
+ "version": "9.122.0",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz",
+ "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@react-spring/three": "~9.7.5",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^2.9.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",
+ "react-composer": "^5.0.3",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.7.8",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.0",
+ "tunnel-rat": "^0.1.2",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^8",
+ "react": "^18",
+ "react-dom": "^18",
+ "three": ">=0.137"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/r3f-perf/node_modules/@react-three/drei/node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "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
+ }
+ }
+ },
+ "node_modules/r3f-perf/node_modules/troika-three-text": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/r3f-perf/node_modules/troika-three-utils": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/r3f-perf/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/r3f-perf/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/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -21730,9 +22145,11 @@
"integrity": "sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ=="
},
"node_modules/three-mesh-bvh": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.6.tgz",
- "integrity": "sha512-rCjsnxEqR9r1/C/lCqzGLS67NDty/S/eT6rAJfDvsanrIctTWdNoR4ZOGWewCB13h1QkVo2BpmC0wakj1+0m8A==",
+ "version": "0.7.8",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz",
+ "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==",
+ "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.",
+ "license": "MIT",
"peerDependencies": {
"three": ">= 0.151.0"
}
diff --git a/package.json b/package.json
index 67e0492..8d66fe9 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"type": "git",
"url": "git+https://github.com/pholmq/TSN.git"
},
- "version": "1.0.3",
+ "version": "1.0.4",
"author": "Patrik Holmqvist",
"description": "The Tychosium www.tychos.space",
"license": "GPL-2.0-or-only",
@@ -15,10 +15,15 @@
"@react-three/fiber": "8.15.19",
"@react-three/postprocessing": "2.19.0",
"@types/three": "0.171.0",
+ "file-saver": "^2.0.5",
"fuse.js": "^7.1.0",
"leva": "0.9.34",
"lodash-es": "^4.17.21",
+ "mediabunny": "^1.37.0",
+ "mobx": "^6.15.0",
+ "mobx-react": "^9.2.1",
"postprocessing": "6.35.2",
+ "r3f-perf": "^7.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "5.4.0",
diff --git a/public/textures/planets/2k_pluto.jpg b/public/textures/planets/2k_pluto.jpg
new file mode 100644
index 0000000..d3a2c65
Binary files /dev/null and b/public/textures/planets/2k_pluto.jpg differ
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
new file mode 100644
index 0000000..8d2cea9
--- /dev/null
+++ b/src/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Change Log
+
+## [1.0.3] - 2026-01-27
+### Added
+- Ephemerides
+- HA Reys Star constellations
+-
diff --git a/src/TSNext.jsx b/src/TSNext.jsx
index a7e756d..592c61c 100644
--- a/src/TSNext.jsx
+++ b/src/TSNext.jsx
@@ -1,3 +1,4 @@
+// src/TSNext.jsx
import "./index.css";
import { useEffect, Suspense, useState } from "react";
import { Canvas } from "@react-three/fiber";
@@ -10,11 +11,14 @@ import PlotSolarSystem from "./components/PlotSolarSystem";
import TraceController from "./components/Trace/TraceController";
import LightEffectsMenu from "./components/Menus/LightEffectsMenu";
import PlanetsPositionsMenu from "./components/Menus/PlanetsPositionsMenu";
-import StarsHelpersMenu from "./components/Menus/StarsHelpersMenu";
import PosController from "./components/PosController";
import Positions from "./components/Menus/Positions";
-import Ephemerides from "./components/Menus/Ephemerides";
-import EphController from "./components/EphController";
+import Ephemerides from "./components/Ephemerides/Ephemerides";
+import EphController from "./components/Ephemerides/EphController";
+import EphemeridesResult from "./components/Ephemerides/EphemeridesResult";
+import EphemeridesProgress from "./components/Ephemerides/EphemeridesProgress";
+import Plot from "./components/Plot/Plot";
+// import PlotController from "./components/Plot/PlotController";
import Stars from "./components/Stars/Stars";
import LabeledStars from "./components/Stars/LabeledStars";
// import BSCStars from "./components/Stars/BSCStars";
@@ -32,7 +36,11 @@ import HighlightSelectedStar from "./components/StarSearch/HighlightSelectedStar
import Help from "./components/Help/Help";
import PlanetCameraCompass from "./components/PlanetCamera/PlanetCameraCompass";
import TransitionCamera from "./components/PlanetCamera/TransitionCamera";
-import Constellations from"./components/Stars/Constellations";
+import Constellations from "./components/Stars/Constellations";
+import PlanetCameraHelper from "./components/PlanetCamera/PlanetCameraHelper";
+import { VideoCanvas } from "./components/Recorder/r3f-video-recorder";
+import RecorderMenu from "./components/Menus/RecorderMenu";
+import RecorderController from "./components/Recorder/RecorderController";
const isTouchDevice = () => {
return (
@@ -67,6 +75,7 @@ const TSNext = () => {
const searchStars = useStore((s) => s.searchStars);
const planetCamera = useStore((s) => s.planetCamera);
const cameraTransitioning = useStore((s) => s.cameraTransitioning);
+ const showPerf = useStore((s) => s.showPerf);
const isTouchDev = isTouchDevice();
@@ -103,20 +112,31 @@ const TSNext = () => {
+
+
+
+
{BSCStarsOn && !isTouchDev && searchStars && }
-
- {/* IntroQuote is always rendered and visible */}
+
{/* Other components wrapped in Suspense */}
@@ -132,8 +152,8 @@ const TSNext = () => {
+ {/* */}
-
@@ -143,8 +163,9 @@ const TSNext = () => {
{BSCStarsOn && !isTouchDev && }
+
-
+
>
);
};
diff --git a/src/components/AnimationController.jsx b/src/components/AnimationController.jsx
index 1f732bf..8e73f95 100644
--- a/src/components/AnimationController.jsx
+++ b/src/components/AnimationController.jsx
@@ -1,5 +1,6 @@
import { useStore } from "../store";
import { useFrame, useThree } from "@react-three/fiber";
+import { useVideoCanvas } from "./Recorder/r3f-video-recorder";
//Note: UserInterface handles most of this but we need to have the useFrame hook
//within the canvas component
@@ -7,20 +8,26 @@ import { useFrame, useThree } from "@react-three/fiber";
const AnimationController = () => {
useStore((s) => s.updAC); //Triggers a rerender when needed
+ const videoCanvas = useVideoCanvas(); // Tap into the deterministic clock
+
const { invalidate, clock } = useThree();
const posRef = useStore((s) => s.posRef);
if (posRef.current == null) posRef.current = 0;
const run = useStore((s) => s.run);
const speedFact = useStore((s) => s.speedFact);
const speedMultiplier = useStore((s) => s.speedMultiplier);
- invalidate();
- clock.getDelta(); //Reset delta so that it's 0 when run becomes true
+
+ // Priority -1 ensures this runs BEFORE components (like planets) read the position
useFrame((state, delta) => {
+ // If actively recording, force a perfect frame step. Otherwise, use real hardware delta.
+ const isRecording = videoCanvas?.recording !== null;
+ const activeDelta = isRecording ? 1 / videoCanvas.fps : delta;
+
if (run) {
- posRef.current = posRef.current + delta * (speedFact * speedMultiplier);
- invalidate(); //Ivalidate frame so we get a render since we have frameloop=demand
+ posRef.current =
+ posRef.current + activeDelta * (speedFact * speedMultiplier);
}
- });
+ }, -1);
return null;
};
diff --git a/src/components/EphController.jsx b/src/components/EphController.jsx
deleted file mode 100644
index 2bab03e..0000000
--- a/src/components/EphController.jsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useEffect, useState } from "react";
-import { useThree } from "@react-three/fiber";
-import { useStore, usePlotStore, useSettingsStore } from "../store";
-import { useEphemeridesStore } from "./Menus/ephemeridesStore";
-import useFrameInterval from "../utils/useFrameInterval";
-import {
- posToDate,
- posToTime,
- dateTimeToPos,
- sDay,
-} from "../utils/time-date-functions";
-import {
- movePlotModel,
- getPlotModelRaDecDistance,
-} from "../utils/plotModelFunctions";
-
-// --- Helper function to save file ---
-const saveEphemeridesAsText = (data, params) => {
- let output = "--- EPHEMERIDES REPORT ---\n";
- output += `Generated on: ${new Date().toLocaleString()}\n`;
- output += `Start Date: ${params.startDate}\n`;
- output += `End Date: ${params.endDate}\n`;
- output += `Step Size: ${params.stepSize} ${params.stepFactor === 1 ? "Days" : "Years"}\n`;
- output += "--------------------------------------\n\n";
-
- Object.keys(data).forEach((planetName) => {
- output += `PLANET: ${planetName.toUpperCase()}\n`;
- // Table Header
- output += `${"Date".padEnd(12)} | ${"Time".padEnd(10)} | ${"RA".padEnd(12)} | ${"Dec".padEnd(12)} | ${"Dist".padEnd(12)} | ${"Elongation".padEnd(10)}\n`;
- output += "-".repeat(80) + "\n";
-
- // Table Rows
- data[planetName].forEach((row) => {
- output += `${row.date.padEnd(12)} | ${row.time.padEnd(10)} | ${row.ra.padEnd(12)} | ${row.dec.padEnd(12)} | ${row.dist.padEnd(12)} | ${row.elong}\n`;
- });
- output += "\n" + "=".repeat(80) + "\n\n";
- });
-
- // Create Blob
- const blob = new Blob([output], { type: "text/plain" });
- const url = URL.createObjectURL(blob);
-
- // Generate Filename from Start and End Dates
- // We replace potential unsafe characters just in case, though standard YYYY-MM-DD is usually safe
- const safeStart = params.startDate.replace(/[:/]/g, "-");
- const safeEnd = params.endDate.replace(/[:/]/g, "-");
- const filename = `Ephemerides_${safeStart}_to_${safeEnd}.txt`;
-
- // Trigger Download
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
-
- // Cleanup
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
-};
-
-const EphController = () => {
- const { scene } = useThree();
- const plotObjects = usePlotStore((s) => s.plotObjects);
-
- const { trigger, params, resetTrigger } = useEphemeridesStore();
-
- const [done, setDone] = useState(true);
-
- useEffect(() => {
- if (trigger && params) {
- const startPos = dateTimeToPos(params.startDate, "00:00:00");
- const endPos = dateTimeToPos(params.endDate, "00:00:00");
- if (params.checkedPlanets.length > 0 && startPos <= endPos) {
- setDone(false);
- }
- resetTrigger();
- }
- }, [trigger, params, resetTrigger]);
-
- useFrameInterval(() => {
- if (done) return;
-
- const { startDate, endDate, stepSize, stepFactor, checkedPlanets } = params;
-
- const startPos = dateTimeToPos(startDate, "00:00:00");
- const endPos = dateTimeToPos(endDate, "00:00:00");
- const increment = stepSize * stepFactor;
-
- const ephemeridesData = {};
- checkedPlanets.forEach((planet) => {
- ephemeridesData[planet] = [];
- });
-
- let currentPos = startPos;
- let steps = 0;
- const MAX_STEPS = 50000;
-
- // console.log("--- Starting Ephemerides Generation ---");
-
- while (currentPos <= endPos && steps < MAX_STEPS) {
- const currentDate = posToDate(currentPos);
- const currentTime = posToTime(currentPos);
-
- movePlotModel(plotObjects, currentPos);
-
- checkedPlanets.forEach((name) => {
- const data = getPlotModelRaDecDistance(name, plotObjects, scene);
-
- if (data) {
- ephemeridesData[name].push({
- date: currentDate,
- time: currentTime,
- ra: data.ra,
- dec: data.dec,
- dist: data.dist,
- elong: data.elongation,
- });
- }
- });
-
- currentPos += increment;
- steps++;
- }
- setDone(true);
-
- // console.log(`Generation Complete. Steps: ${steps}`);
-
- // Trigger the save file dialog with the new naming convention
- saveEphemeridesAsText(ephemeridesData, params);
- });
-
- return null;
-};
-export default EphController;
\ No newline at end of file
diff --git a/src/components/Ephemerides/EphController.jsx b/src/components/Ephemerides/EphController.jsx
new file mode 100644
index 0000000..d6dedbb
--- /dev/null
+++ b/src/components/Ephemerides/EphController.jsx
@@ -0,0 +1,149 @@
+import { useEffect, useState, useRef } from "react";
+import { useThree, useFrame } from "@react-three/fiber";
+import { usePlotStore } from "../../store";
+import { useEphemeridesStore } from "./ephemeridesStore";
+import {
+ posToDate,
+ posToTime,
+ dateTimeToPos,
+} from "../../utils/time-date-functions";
+import {
+ movePlotModel,
+ getPlotModelRaDecDistance,
+} from "../../utils/plotModelFunctions";
+
+const EphController = () => {
+ const { scene, invalidate } = useThree(); // invalidate is grabbed here
+ const plotObjects = usePlotStore((s) => s.plotObjects);
+
+ const {
+ trigger,
+ params,
+ resetTrigger,
+ setGeneratedData,
+ setGenerationError,
+ setIsGenerating,
+ setProgress,
+ } = useEphemeridesStore();
+
+ const [generating, setGenerating] = useState(false);
+
+ const jobRef = useRef({
+ startPos: 0,
+ currentStep: 0,
+ totalSteps: 0,
+ increment: 0,
+ checkedPlanets: [],
+ data: {},
+ lastProgress: 0,
+ });
+
+ // 1. Initialize Job
+ useEffect(() => {
+ if (trigger && params) {
+ setIsGenerating(true);
+ setProgress(0);
+
+ const startPos = dateTimeToPos(params.startDate, "00:00:00");
+ const endPos = dateTimeToPos(params.endDate, "00:00:00");
+ let increment = params.stepSize * params.stepFactor;
+
+ // Reverse direction if Start > End
+ if (startPos > endPos) {
+ increment = -increment;
+ }
+
+ const totalSteps = Math.round((endPos - startPos) / increment);
+
+ // Initialize Data Structure
+ const initialData = {};
+ params.checkedPlanets.forEach((planet) => {
+ initialData[planet] = [];
+ });
+
+ // Setup Job
+ jobRef.current = {
+ startPos: startPos,
+ currentStep: 0,
+ totalSteps: totalSteps,
+ increment: increment,
+ checkedPlanets: params.checkedPlanets,
+ data: initialData,
+ lastProgress: 0,
+ };
+
+ setGenerating(true);
+ resetTrigger();
+ }
+ }, [
+ trigger,
+ params,
+ resetTrigger,
+ setGenerationError,
+ setIsGenerating,
+ setProgress,
+ ]);
+
+ // 2. Process Job in Chunks
+ useFrame(() => {
+ // Check for Cancellation
+ if (generating && !useEphemeridesStore.getState().isGenerating) {
+ setGenerating(false);
+ return;
+ }
+
+ if (!generating) return;
+
+ // Keep the loop alive while generating
+ invalidate();
+
+ const job = jobRef.current;
+ const BATCH_SIZE = 50;
+ let batchCount = 0;
+
+ while (job.currentStep <= job.totalSteps && batchCount < BATCH_SIZE) {
+ const currentPos = job.startPos + job.currentStep * job.increment;
+ const currentDate = posToDate(currentPos);
+ const currentTime = posToTime(currentPos);
+
+ movePlotModel(plotObjects, currentPos);
+
+ job.checkedPlanets.forEach((name) => {
+ const data = getPlotModelRaDecDistance(name, plotObjects, scene);
+ if (data) {
+ job.data[name].push({
+ date: currentDate,
+ time: currentTime,
+ ra: data.ra,
+ dec: data.dec,
+ dist: data.dist,
+ elong: data.elongation,
+ });
+ }
+ });
+
+ job.currentStep++;
+ batchCount++;
+ }
+
+ const progress = Math.min(
+ 100,
+ Math.floor((job.currentStep / (job.totalSteps + 1)) * 100)
+ );
+
+ if (progress > job.lastProgress) {
+ setProgress(progress);
+ job.lastProgress = progress;
+ }
+
+ // Completion Check
+ if (job.currentStep > job.totalSteps) {
+ setGeneratedData(job.data);
+ setGenerating(false);
+ setProgress(100);
+ }
+ });
+
+ return null;
+};
+export default EphController;
diff --git a/src/components/Ephemerides/Ephemerides.jsx b/src/components/Ephemerides/Ephemerides.jsx
new file mode 100644
index 0000000..d6231ea
--- /dev/null
+++ b/src/components/Ephemerides/Ephemerides.jsx
@@ -0,0 +1,226 @@
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+import { useControls, useCreateStore, Leva, button } from "leva";
+import { useStore, useSettingsStore } from "../../store";
+import {
+ isValidDate,
+ posToDate,
+ speedFactOpts,
+ sDay,
+} from "../../utils/time-date-functions";
+import { useEphemeridesStore } from "./ephemeridesStore";
+
+const Ephemerides = () => {
+ const { ephimerides, setEphemerides, posRef } = useStore();
+ const { settings } = useSettingsStore();
+
+ const setGenerationParams = useEphemeridesStore((s) => s.setGenerationParams);
+ const setGenerationError = useEphemeridesStore((s) => s.setGenerationError);
+ const isGenerating = useEphemeridesStore((s) => s.isGenerating);
+
+ const levaEphStore = useCreateStore();
+
+ const valuesRef = useRef({
+ "Start Date": posToDate(posRef.current),
+ "End Date": posToDate(posRef.current),
+ "Step size": 1,
+ "\u{000D}": sDay,
+ });
+
+ const checkboxes = {};
+ settings.forEach((s) => {
+ if (s.type === "planet" && s.name !== "Earth") {
+ if (valuesRef.current[s.name] === undefined) {
+ valuesRef.current[s.name] = false;
+ }
+ checkboxes[s.name] = {
+ value: false,
+ onChange: (v) => {
+ valuesRef.current[s.name] = v;
+ },
+ };
+ }
+ });
+
+ const handleCreate = () => {
+ if (isGenerating) return;
+
+ const formValues = valuesRef.current;
+
+ const checkedPlanets = settings
+ .filter((s) => s.type === "planet" && s.name !== "Earth")
+ .filter((s) => formValues[s.name] === true)
+ .map((s) => s.name);
+
+ if (checkedPlanets.length === 0) {
+ setGenerationError(
+ "No planets selected.\nPlease select at least one planet to generate data."
+ );
+ return;
+ }
+
+ setGenerationParams({
+ startDate: formValues["Start Date"],
+ endDate: formValues["End Date"],
+ stepSize: formValues["Step size"],
+ stepFactor: formValues["\u{000D}"],
+ checkedPlanets,
+ });
+ };
+
+ const handleInvalidInput = (field, fallbackValue) => {
+ levaEphStore.set({ [field]: fallbackValue });
+ valuesRef.current[field] = fallbackValue;
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+ };
+
+ useEffect(() => {
+ if (ephimerides) {
+ const currentDate = posToDate(posRef.current);
+
+ valuesRef.current["Start Date"] = currentDate;
+ valuesRef.current["End Date"] = currentDate;
+
+ levaEphStore.set({
+ "Start Date": currentDate,
+ "End Date": currentDate,
+ });
+ }
+ }, [ephimerides, posRef, levaEphStore]);
+
+ // Highly targeted DOM injection for the X button
+ useEffect(() => {
+ if (!ephimerides) return;
+
+ const interval = setInterval(() => {
+ // Find the deepest div containing ONLY the exact title text
+ const textDiv = Array.from(document.querySelectorAll("div")).find(
+ (el) =>
+ el.textContent.trim() === "Ephemerides" && el.children.length === 0
+ );
+
+ if (textDiv) {
+ // Leva's title bar is the immediate flex container wrapping this text
+ const titleBar = textDiv.parentElement;
+
+ if (titleBar && !titleBar.querySelector(".leva-close-x")) {
+ // Allow the title bar to anchor our absolutely positioned button
+ titleBar.style.position = "relative";
+
+ const closeBtn = document.createElement("div");
+ closeBtn.className = "leva-close-x";
+ closeBtn.innerHTML = "✕";
+
+ // Style it seamlessly into the top right corner
+ Object.assign(closeBtn.style, {
+ position: "absolute",
+ right: "12px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ cursor: "pointer",
+ color: "#8C92A4",
+ fontSize: "14px",
+ fontWeight: "bold",
+ padding: "4px",
+ zIndex: "9999",
+ });
+
+ // Native hover colors
+ closeBtn.onmouseenter = () => (closeBtn.style.color = "#FFFFFF");
+ closeBtn.onmouseleave = () => (closeBtn.style.color = "#8C92A4");
+
+ // CRITICAL: Stop the click from passing through and triggering Leva's drag feature
+ closeBtn.onmousedown = (e) => e.stopPropagation();
+
+ // Close action
+ closeBtn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setEphemerides(false);
+ };
+
+ titleBar.appendChild(closeBtn);
+ }
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, [ephimerides, setEphemerides]);
+
+ useControls(
+ {
+ Generate: button(handleCreate, { disabled: isGenerating }),
+ "Start Date": {
+ value: posToDate(posRef.current),
+ onChange: (v) => {
+ valuesRef.current["Start Date"] = v;
+ },
+ onEditEnd: (value) => {
+ if (!isValidDate(value))
+ handleInvalidInput("Start Date", posToDate(posRef.current));
+ },
+ },
+ "End Date": {
+ value: posToDate(posRef.current),
+ onChange: (v) => {
+ valuesRef.current["End Date"] = v;
+ },
+ onEditEnd: (value) => {
+ if (!isValidDate(value))
+ handleInvalidInput("End Date", posToDate(posRef.current));
+ },
+ },
+ "Step size": {
+ value: 1,
+ onChange: (v) => {
+ valuesRef.current["Step size"] = v;
+ },
+ onEditEnd: (value) => {
+ const num = parseFloat(value);
+ if (isNaN(num) || num <= 0) handleInvalidInput("Step size", 1);
+ },
+ },
+ "\u{000D}": {
+ value: sDay,
+ options: speedFactOpts,
+ onChange: (v) => {
+ valuesRef.current["\u{000D}"] = v;
+ },
+ },
+ ...checkboxes,
+ },
+ { store: levaEphStore },
+ [settings, isGenerating]
+ );
+
+ if (!ephimerides) return null;
+
+ return createPortal(
+
+
+
,
+ document.body
+ );
+};
+
+export default Ephemerides;
diff --git a/src/components/Ephemerides/EphemeridesProgress.jsx b/src/components/Ephemerides/EphemeridesProgress.jsx
new file mode 100644
index 0000000..4df27d0
--- /dev/null
+++ b/src/components/Ephemerides/EphemeridesProgress.jsx
@@ -0,0 +1,100 @@
+import React from "react";
+import { createPortal } from "react-dom";
+import { useEphemeridesStore } from "./ephemeridesStore";
+
+const EphemeridesProgress = () => {
+ const isGenerating = useEphemeridesStore((s) => s.isGenerating);
+ const progress = useEphemeridesStore((s) => s.progress);
+ const cancelGeneration = useEphemeridesStore((s) => s.cancelGeneration);
+
+ if (!isGenerating) return null;
+
+ return createPortal(
+
+
+
+ Generating Ephemerides data
+
+
+ {progress}%
+
+
+
+ {/* Progress Bar Container */}
+
+
+ {/* Cancel Button */}
+
+ {
+ e.currentTarget.style.backgroundColor = "rgba(248, 113, 113, 0.1)";
+ }}
+ onMouseOut={(e) => {
+ e.currentTarget.style.backgroundColor = "transparent";
+ }}
+ >
+ Cancel
+
+
+
,
+ document.body
+ );
+};
+
+export default EphemeridesProgress;
diff --git a/src/components/Ephemerides/EphemeridesResult.jsx b/src/components/Ephemerides/EphemeridesResult.jsx
new file mode 100644
index 0000000..d6b8f3a
--- /dev/null
+++ b/src/components/Ephemerides/EphemeridesResult.jsx
@@ -0,0 +1,294 @@
+import React, { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { useEphemeridesStore } from "./ephemeridesStore";
+import { FaSave, FaExclamationTriangle } from "react-icons/fa";
+import { speedFactOpts } from "../../utils/time-date-functions";
+
+const EphemeridesResult = () => {
+ const { showResult, generatedData, generationError, params, closeResult } =
+ useEphemeridesStore();
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+ const [previewText, setPreviewText] = useState("");
+
+ // --- Formatting Logic ---
+ const formatDataToText = (data, parameters) => {
+ if (!data || !parameters) return "";
+
+ const displayUnit = Object.keys(speedFactOpts).find(
+ (key) => speedFactOpts[key] === parameters.stepFactor
+ );
+
+ let output = "--- EPHEMERIDES REPORT ---\n";
+ output += `Generated on: ${new Date().toLocaleString()}\n`;
+ output += `Start Date: ${parameters.startDate}\n`;
+ output += `End Date: ${parameters.endDate}\n`;
+ output += `Step Size: ${parameters.stepSize} ${displayUnit}\n`;
+ output += "--------------------------------------\n\n";
+
+ Object.keys(data).forEach((planetName) => {
+ output += `PLANET: ${planetName.toUpperCase()}\n`;
+ output += `${"Date".padEnd(12)} | ${"Time".padEnd(10)} | ${"RA".padEnd(
+ 12
+ )} | ${"Dec".padEnd(12)} | ${"Dist".padEnd(12)} | ${"Elongation".padEnd(
+ 10
+ )}\n`;
+ output += "-".repeat(80) + "\n";
+
+ data[planetName].forEach((row) => {
+ output += `${row.date.padEnd(12)} | ${row.time.padEnd(
+ 10
+ )} | ${row.ra.padEnd(12)} | ${row.dec.padEnd(12)} | ${row.dist.padEnd(
+ 12
+ )} | ${row.elong}\n`;
+ });
+ output += "\n" + "=".repeat(80) + "\n\n";
+ });
+
+ return output;
+ };
+
+ useEffect(() => {
+ if (generatedData && params) {
+ setPreviewText(formatDataToText(generatedData, params));
+ }
+ }, [generatedData, params]);
+
+ // --- Dragging Logic ---
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!isDragging) return;
+ setPosition({
+ x: position.x + (e.clientX - dragStart.x),
+ y: position.y + (e.clientY - dragStart.y),
+ });
+ setDragStart({ x: e.clientX, y: e.clientY });
+ };
+
+ const handleMouseUp = () => setIsDragging(false);
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isDragging, dragStart, position]);
+
+ const handleMouseDown = (e) => {
+ if (e.target.closest(".popup-header")) {
+ setIsDragging(true);
+ setDragStart({ x: e.clientX, y: e.clientY });
+ }
+ };
+
+ const handleSave = () => {
+ if (!previewText || !params) return;
+ const blob = new Blob([previewText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const safeStart = params.startDate.replace(/[:/]/g, "-");
+ const safeEnd = params.endDate.replace(/[:/]/g, "-");
+ const filename = `Ephemerides_${safeStart}_to_${safeEnd}.txt`;
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ };
+
+ if (!showResult) return null;
+
+ return createPortal(
+
+ {/* Header */}
+
+
+ {generationError ? (
+
+ ) : (
+
+ )}
+
+ {generationError ? "Error" : "Ephemerides result"}
+
+
+
+ ✕
+
+
+
+ {/* Content Body */}
+
+ {generationError ? (
+
+ {/*
+ Calculation Stopped
+
*/}
+
+ {generationError}
+
+
+ ) : (
+
+
+ Click 'Save' to download as a text file.
+
+
+
+ )}
+
+
+ {/* Footer Actions */}
+
+ {generationError ? (
+
+ OK
+
+ ) : (
+ <>
+
+ Close
+
+
+ Save to File
+
+ >
+ )}
+
+
,
+ document.body
+ );
+};
+
+export default EphemeridesResult;
diff --git a/src/components/Ephemerides/ephemeridesStore.js b/src/components/Ephemerides/ephemeridesStore.js
new file mode 100644
index 0000000..aa72011
--- /dev/null
+++ b/src/components/Ephemerides/ephemeridesStore.js
@@ -0,0 +1,58 @@
+import { create } from "zustand";
+
+export const useEphemeridesStore = create((set, get) => ({
+ trigger: false,
+ params: null,
+ isGenerating: false,
+ showResult: false,
+ generatedData: null,
+ generationError: null,
+ progress: 0,
+
+ setGenerationParams: (params) =>
+ set({
+ trigger: true,
+ params,
+ showResult: false,
+ generatedData: null,
+ generationError: null,
+ isGenerating: true,
+ progress: 0,
+ }),
+
+ resetTrigger: () => set({ trigger: false }),
+
+ setIsGenerating: (isGenerating) => set({ isGenerating }),
+
+ setProgress: (progress) => set({ progress }),
+
+ cancelGeneration: () =>
+ set({
+ isGenerating: false,
+ progress: 0,
+ trigger: false,
+ }),
+
+ setGeneratedData: (data) =>
+ set({
+ generatedData: data,
+ showResult: true,
+ generationError: null,
+ isGenerating: false,
+ }),
+
+ setGenerationError: (error) =>
+ set({
+ generationError: error,
+ showResult: true,
+ generatedData: null,
+ isGenerating: false,
+ }),
+
+ closeResult: () =>
+ set({
+ showResult: false,
+ generatedData: null,
+ generationError: null,
+ }),
+}));
diff --git a/src/components/Help/Help.jsx b/src/components/Help/Help.jsx
index 082cb5b..6be088d 100644
--- a/src/components/Help/Help.jsx
+++ b/src/components/Help/Help.jsx
@@ -1,8 +1,12 @@
import React, { useState, useRef, useEffect } from "react";
+import { createPortal } from "react-dom";
import { useStore } from "../../store";
import { FaExternalLinkAlt, FaGithub } from "react-icons/fa";
import ReactMarkdown from "react-markdown";
import helpContent from "./HelpContent.md";
+import TychosLogoIcon from "../../utils/TychosLogoIcon";
+
+// Clean geometric SVG of the TYCHOS model
const Help = () => {
const showHelp = useStore((s) => s.showHelp);
@@ -62,26 +66,40 @@ const Help = () => {
if (!showHelp) return null;
- return (
+ return createPortal(
{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
- padding: "20px 30px",
+ height: "28px",
+ padding: "0 8px",
+ backgroundColor: "#181c20",
+ borderBottom: "1px solid #181c20",
+ borderTopLeftRadius: "6px",
+ borderTopRightRadius: "6px",
cursor: isDragging ? "grabbing" : "grab",
- borderBottom: "1px solid #374151",
- backgroundColor: "#111827",
+ flexShrink: 0,
}}
>
-
-
- The TYCHOSIUM
-
-
-
+ {/* Replaced with standard matching wrapper and added binary star symbol */}
+
+
+ The Tychosium
+
+
+
+
{
- e.stopPropagation(); // Prevent triggering drag
+ e.stopPropagation();
window.open("https://www.tychos.space", "_blank");
}}
- style={{ fontSize: "18px" }}
+ style={{
+ color: "#8C92A4",
+ cursor: "pointer",
+ fontSize: "12px",
+ display: "flex",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#FFFFFF")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#8C92A4")}
>
-
-
+ {
- e.stopPropagation(); // Prevent triggering drag
+ e.stopPropagation();
window.open("https://github.com/pholmq/TSN", "_blank");
}}
- style={{ fontSize: "18px" }}
+ style={{
+ color: "#8C92A4",
+ cursor: "pointer",
+ fontSize: "13px",
+ display: "flex",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#FFFFFF")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#8C92A4")}
>
-
+
- setShowHelp(false)}
style={{
- background: "#374151",
- border: "none",
- borderRadius: "6px",
- padding: "8px 12px",
- color: "white",
cursor: "pointer",
- fontSize: "16px",
+ color: "#8C92A4",
+ fontSize: "14px",
+ fontWeight: "bold",
+ padding: "4px",
+ marginRight: "-2px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#FFFFFF")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#8C92A4")}
+ title="Close Help"
>
✕
-
+
(
{children}
@@ -174,9 +226,9 @@ const Help = () => {
{children}
@@ -186,21 +238,21 @@ const Help = () => {
{children}
),
ul: ({ children }) => (
-
+
),
li: ({ children }) => (
- {children}
+ {children}
),
p: ({ children }) => (
{children}
@@ -223,7 +275,8 @@ const Help = () => {
{markdownContent}
-
+
,
+ document.body
);
};
diff --git a/src/components/Help/HelpContent.md b/src/components/Help/HelpContent.md
index 78b8f22..2f1eb9e 100644
--- a/src/components/Help/HelpContent.md
+++ b/src/components/Help/HelpContent.md
@@ -1,73 +1,97 @@
-# 💫 Welcome to The TYCHOSIUM
+# Welcome to The TYCHOSIUM
-The TYCHOSIUM is an interactive 3D simulation of the **TYCHOS model** of our solar system. It allows you to visualize celestial mechanics from a unique binary system perspective, where Earth serves as the reference point.
+The Tychosium is an interactive 3D simulator of the TYCHOS model of our solar system. It allows you to visualize the intricate orbital mechanics of our nearby celestial bodies, trace their paths over time, generate Epemerides (lists of planetary positions), discover and examine astronomical events such as conjunctions and oppositions, elcipses, etc.
----
+## Features
-## ☼ Navigation & Viewing
-
-### **Orbit View (Default)**
-Move freely around the solar system.
-- **Rotate:** Left-click + Drag
-- **Zoom:** Mouse Wheel / Scroll
-- **Pan:** Right-click + Drag
-- **Focus:** Double-click on any object to center the camera on it
-- **Context Menu:** Right-click an object to see more options
-
-### 🌍 **Planet Camera (Surface View)**
-Experience the sky from the surface of Earth or other celestial bodies.
-1. Open the **Controls** menu (top right).
-2. Check **"Planet camera"**.
-3. Use the **Planet Camera** panel to:
- - **Select Body:** Switch between Earth, Moon, Mars, Mercury, Venus, or Sun.
- - **Jump to Location:** Use the **Location** dropdown to instantly visit cities (e.g., *Rome, Stockholm, Tokyo*).
- - **Manual Position:** Adjust **Latitude**, **Longitude**, and **Height** manually.
- - **Look Around:** Drag the mouse to rotate your viewing angle and direction.
+- **All planets of the Solar system** arranged in the TYCHOS model configuration in an actual Euclidian space. The simulation also includes Halleys comet and Eros.
+- **9000+ stars** with Right Ascension, Declination, Magnitude and Color temperature.
+- **Search** All stars, planets, comets and asteroids can be searched and tracked.
+- **Accurate scale and distances** Whith `Actual planet` sizes checked all celectial bodies are to scale and at correct relative distances. In the `Stars & Helpers` menu, the Star distances can be adjusted. Default is `42633` - the reduction factor suggested in the TYCHOS model, but if set to `1` the official star distances are used. When `Celstial sphere` is checked, the stars are projected at a uniform distance that can be adjusted with `Sphere & Zodiac size`
+- **Planet camera** When selecting `Planet camera` the camera is attched to Earth, with numerous location presets and its also possible to view the universe from other planets.
+- **Trace** Planetary motion and retogrades can be visualized.
+- **Constellations and Zodiac wheels** In the `Stars & Helpers` the H.A. Reys beatiful way to draw constellation can be turned on, plus the tropical and sidereal zodiac and the Equinoxes to study the Precession of the Equinoxes.
+- **Perpetual calendar** The simulation allows for pratically and date and accunts for the Julian calendar and supports Julian astronomical dates.
---
-## ⏱ Time Controls
+## Basic Navigation
+
+- **Rotate Camera:** Left-Click + Drag
+- **Zoom:** Mouse Scroll Wheel
+- **Change camera target:** Double click on a planet
+- **Hoover any planet or star** to see its current position
+
+---
-Control the flow of time to observe planetary motions over centuries or days.
+## Time & Main Controls (Top Right Menu)
- - **Reset:** Return to the simulation start date (2000-01-01).
- - **Today:** Instantly jump to the current real-world date.
-- **Play/Pause:** Start/Stop the simulation (Key: `Space`).
-- **Step Forward/Back:** Move time by the selected increment (Day, Month, Year).
-- **Date:** Enter a Gregorian date (YYYY-MM-DD).
-- **Time:** Enter a UTC time.
-- **Julian Day:** Enter a scientific Julian Day number.
-- **1 sec/step equals:** Adjust how fast time passes when playing. Negative numbers run the simulation in reverse.
-- **Increment:** Adjust what 1 sec/step (and multiplier) equals (second, minute, hour...).
+The primary panel controls the flow of time in the simulator.
+- **Question mark** Shows this window.
+- **Stripes and X** Hides/Shows the main menu.
+- **Reset:** Restores the simulator to the default start date (2000-06-21).
+- **Today:** Sets todays date.
+- **Playback Controls:** - **Play/Pause (▶ / ⏸):** Starts or stops the progression of time.
+ - **Step Back / Step Forward (⏮ / ⏭):** Click to step one unit of time, or click and hold to move through time continuously.
+- **Date, Time (UTC), & Julian Day:** Manually enter specific dates or times. Press `Enter` to instantly jump to that exact moment.
---
-## 🔭 Stars & Search
+## Speed Controls
-- **Star Data:** Hover your mouse over any star to reveal its Name, HR Number, Magnitude, and Color Index.
-- **Search:** Use the search bar (top left) to find stars by:
- - **Name** (e.g., "Sirius", "Polaris")
- - **HR Number** (e.g., "HR 123")
-- **Highlight:** Selected stars are marked with a crosshair for easy tracking.
+- **1 sec/step equals:** Sets the multiplier for time progression. You can use `Up/Down` on your keyboard to add/remove 1 increments. Negative numbers causes the simulation to move backwards.
+- **Time Unit (Dropdown):** Choose the unit of time for each step/second (e.g., Seconds, Hours, Days, Solar Years, Sidereal Years).
----
+## Main Menu
-## 🛠️ Visual Settings & Tools
+Directly below the time controls is the main menu, divided into expandable sections.
-Use the **Controls Panel** (top right) to customize your view:
+### Controls
-### **Visibility**
-- **Planets:** Show/hide specific planets or the Moon.
-- **Orbits:** Toggle orbital paths to see the geometry of the system.
-- **Labels:** Toggle text labels for planets and major stars.
+Toggles for primary viewing modes and control panels.
-### **Helpers**
-- **Celestial Sphere:** Show the grid of Right Ascension and Declination.
-- **Ecliptic Grid:** Visualize the plane of the solar system.
-- **Zodiac:** Display the zodiacal band for astrological reference.
-- **Trace:** Enable **Orbital Tracing** to draw the geometric path of a planet over time (Spirograph effect).
+- **Actual planet sizes:** Toggles between visually scaled-up planets and their true, realistic scale.
+- **Planet camera:** Activates the Planet camera.
+- **Camera follow:** Locks the camera to follow the planet that is camera target.
+- **Labels:** Shows or hides the names of planets and stars.
+- **Orbits:** Toggles the visibility of planetary orbit lines.
+- **Search:** Opens the **Search** tool to find and track specific stars or planets.
+- **Positions:** Opens a live data panel showing the Right Ascension (RA), Declination (Dec), Distance, and Elongation of the planets.
+- **Ephemerides:** Opens the Ephemerides generator that calculate historical or future planetary data over custom date ranges.
----
+### Trace
+
+Tool for drawing the paths that planets take through space over time.
+
+**Note:** To trace the Sun you need to set the 1 sec/step equals to 100 years or higher, since the trace shows the Suns motion during a great year
+
+- **Trace On/Off:** Enables tracing.
+- **Line width & Dotted line:** Customizes the appearance of the trace lines.
+- **Trace length:** Determines how far back in time the trace tail extends.
+- **Step length:** Adjusts the resolution/smoothness of the generated trace.
+- **Planets:** Select which planets that should be traced.
+
+### Planets & Orbits
+
+Adjust the visual representation of the solar system.
+
+- **Planet sizes:** Slider to manually scale up planets for better visibility.
+- **Orbits linewidth:** Adjusts the thickness of the orbit paths.
+- **Arrows:** Shows directional arrows on the orbital paths.
+- **Polar lines:** Toggles the visibility of polar axis lines on the Earth and the Sun.
+- **Graticules:** Toggles a spherical coordinate grid over the Earth and the Sun.
+- **Edit settings:** Opens advanced developer options for modifying object parameters.
+- **Planets:** Select which planets, moons, comets and asteroids to view.
+
+### Stars & Helpers
+
+Controls the fixed stars, consellation visibility and Equinox and Zodiac markers.
-[**Learn more about the TYCHOS model at tychos.space →**](https://www.tychos.space)
\ No newline at end of file
+- **Stars:** Toggles the visibility of the background starfield (Bright Star Catalogue).
+- **Divide distances by:** Pulls the stars closer/further without changing their coordinate positions.
+- **Celestial sphere:** Projects all stars onto a uniform sphere at an equidistant radius.
+- **Constellations:** Draws H.A. Reys classical constellations.
+- **Equinoxes & Solistices:** Marks the Equinoxes and Solistices.
+- **Sidereal Zodiac / Tropical Zodiac:** Displays the respective zodiacs around the solar system.
+- **Sphere & Zodiac size:** Scales the radius of the celestial sphere, the constellations and helpers..
diff --git a/src/components/Helpers/CelestialSphere.jsx b/src/components/Helpers/CelestialSphere.jsx
index 1acd4ae..98f6a60 100644
--- a/src/components/Helpers/CelestialSphere.jsx
+++ b/src/components/Helpers/CelestialSphere.jsx
@@ -10,7 +10,8 @@ function CelestialSphere() {
const meshRef = useRef();
const wireframeRef = useRef();
- const size = (celestialSphereSize * hScale) / 100;
+ // Apply a 2/3 scaling factor to make it one-third smaller
+ const size = ((celestialSphereSize * hScale) / 100) * (2 / 3);
// Recreate PolarGridHelper whenever celestialSphereSize changes
const polarGrid = useMemo(
diff --git a/src/components/Helpers/EclipticGrid.jsx b/src/components/Helpers/EclipticGrid.jsx
index ab47473..d4a8f56 100644
--- a/src/components/Helpers/EclipticGrid.jsx
+++ b/src/components/Helpers/EclipticGrid.jsx
@@ -11,19 +11,29 @@ export default function EclipticGrid() {
const gridGroup = useMemo(() => {
const group = new THREE.Group();
- // Create the main grid helper
- const grid = new THREE.GridHelper(2, 30, "#008800", "#000088");
- group.add(grid);
+ // Create only the green lines for the X and Z axes
+ const points = [
+ new THREE.Vector3(-1, 0, 0), // X-axis start
+ new THREE.Vector3(1, 0, 0), // X-axis end
+ new THREE.Vector3(0, 0, -1), // Z-axis start
+ new THREE.Vector3(0, 0, 1), // Z-axis end
+ ];
+
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
+ const material = new THREE.LineBasicMaterial({ color: "#008800" });
+ const greenLines = new THREE.LineSegments(geometry, material);
+
+ group.add(greenLines);
return group;
- }, [eclipticGridSize]);
+ }, []); // eclipticGridSize removed from dependency array as it's not used inside useMemo
if (!eclipticGrid) return null;
const size = (eclipticGridSize * hScale) / 100;
return (
-
+
{/* Seasonal Markers */}
diff --git a/src/components/Helpers/TropicalZodiac.jsx b/src/components/Helpers/TropicalZodiac.jsx
new file mode 100644
index 0000000..909ed97
--- /dev/null
+++ b/src/components/Helpers/TropicalZodiac.jsx
@@ -0,0 +1,150 @@
+import { useMemo } from "react";
+import * as THREE from "three";
+import { CanvasTexture, DoubleSide } from "three";
+import getCircularText from "../../utils/getCircularText";
+import { useStore } from "../../store";
+
+function ZodiacGrid() {
+ const geometry = useMemo(() => {
+ const points = [];
+ const radius = 260;
+ const radials = 12;
+ const divisions = 64;
+
+ // 12 Visible Spokes
+ for (let i = 0; i < radials; i++) {
+ const angle = (i / radials) * Math.PI * 2;
+ const x = Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+ points.push(new THREE.Vector3(0, 0, 0));
+ points.push(new THREE.Vector3(x, 0, z));
+ }
+
+ // Outer Circle
+ for (let i = 0; i < divisions; i++) {
+ const t1 = (i / divisions) * Math.PI * 2;
+ const t2 = ((i + 1) / divisions) * Math.PI * 2;
+ points.push(
+ new THREE.Vector3(Math.cos(t1) * radius, 0, Math.sin(t1) * radius)
+ );
+ points.push(
+ new THREE.Vector3(Math.cos(t2) * radius, 0, Math.sin(t2) * radius)
+ );
+ }
+
+ return new THREE.BufferGeometry().setFromPoints(points);
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+function ZodiacLabels() {
+ const names =
+ " GEMINI" +
+ " TAURUS" +
+ " ARIES" +
+ " PISCES" +
+ " AQUARIUS" +
+ " CAPRICORN" +
+ " SAGITTARIUS" +
+ " SCORPIO" +
+ " LIBRA" +
+ " VIRGO" +
+ " LEO" +
+ " CANCER";
+ const text1 = getCircularText(
+ names,
+ 800,
+ 0,
+ "right",
+ false,
+ true,
+ "Arial",
+ "18pt",
+ 2
+ );
+ const texture1 = new CanvasTexture(text1);
+
+ const symbols =
+ " ♊" +
+ " ♉" +
+ " ♈" +
+ " ♓" +
+ " ♒" +
+ " ♑" +
+ " ♐" +
+ " ♏" +
+ " ♎" +
+ " ♍" +
+ " ♌" +
+ " ♋";
+ const text2 = getCircularText(
+ symbols,
+ 800,
+ 0,
+ "right",
+ false,
+ true,
+ "Segoe UI Symbol",
+ "18pt",
+ 2
+ );
+ const texture2 = new CanvasTexture(text2);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function TropicalZodiac() {
+ const tropicalZodiac = useStore((s) => s.tropicalZodiac);
+ const zodiacSize = 100;
+ const hScale = useStore((s) => s.hScale);
+ const size = (zodiacSize * hScale) / 100;
+
+ // CALIBRATION: Adjust this if Aries doesn't align with the Vernal Equinox vector
+ // -1.57 is -90 degrees (standard 3 o'clock start offset)
+ const ROTATION_OFFSET = -Math.PI / 2;
+
+ return (
+ <>
+ {tropicalZodiac && (
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/Helpers/Zodiac.jsx b/src/components/Helpers/Zodiac.jsx
index dc35677..7ae3cfd 100644
--- a/src/components/Helpers/Zodiac.jsx
+++ b/src/components/Helpers/Zodiac.jsx
@@ -1,6 +1,51 @@
+import { useMemo } from "react"; // Added useMemo
+import * as THREE from "three"; // Import THREE for Vector3 and BufferGeometry
import { CanvasTexture, DoubleSide } from "three";
import getCircularText from "../../utils/getCircularText";
import { useStore } from "../../store";
+
+// New component to replace PolarGridHelper
+function ZodiacGrid() {
+ const geometry = useMemo(() => {
+ const points = [];
+ const radius = 260; // Matches original args[0]
+ const radials = 12; // Changed from 24 to 12 (Only visible spokes)
+ const divisions = 64; // Matches original args[3] (Circle smoothness)
+
+ // 1. Generate the 12 visible spokes
+ for (let i = 0; i < radials; i++) {
+ const angle = (i / radials) * Math.PI * 2;
+ const x = Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+
+ // Line from center (0,0,0) to outer edge
+ points.push(new THREE.Vector3(0, 0, 0));
+ points.push(new THREE.Vector3(x, 0, z));
+ }
+
+ // 2. Generate the outer circle
+ for (let i = 0; i < divisions; i++) {
+ const t1 = (i / divisions) * Math.PI * 2;
+ const t2 = ((i + 1) / divisions) * Math.PI * 2;
+
+ points.push(
+ new THREE.Vector3(Math.cos(t1) * radius, 0, Math.sin(t1) * radius)
+ );
+ points.push(
+ new THREE.Vector3(Math.cos(t2) * radius, 0, Math.sin(t2) * radius)
+ );
+ }
+
+ return new THREE.BufferGeometry().setFromPoints(points);
+ }, []);
+
+ return (
+
+ {/* Only the visible grey color */}
+
+ );
+}
+
function ZodiacLabels() {
const names =
" GEMINI" +
@@ -89,11 +134,12 @@ export default function Zodiac() {
<>
{zodiac && (
-
+ {/* Replaced polarGridHelper with custom ZodiacGrid */}
+
)}
diff --git a/src/components/HoverObj/HoverObj.jsx b/src/components/HoverObj/HoverObj.jsx
index 1150eea..cbdaf2e 100644
--- a/src/components/HoverObj/HoverObj.jsx
+++ b/src/components/HoverObj/HoverObj.jsx
@@ -1,32 +1,65 @@
-import { useRef, useState } from "react";
-import {SpriteMaterial} from "three";
-import { useStore } from "../../store";
-import HoverPanel from "./HoverPanel"; // Relative import
-import createCircleTexture from "../../utils/createCircleTexture"; // Adjust path if utils/ is elsewhere
+import { useRef, useState, useMemo, useEffect } from "react";
+// Remove SpriteMaterial import, we will use the JSX element
+import { useStore } from "../../store";
+import HoverPanel from "./HoverPanel";
+import createCircleTexture from "../../utils/createCircleTexture";
+import { useThree } from "@react-three/fiber"; // 1. Import useThree
-const HoverObj = ({ s, starColor=false }) => {
+const HoverObj = ({ s, starColor = false }) => {
const [hovered, setHover] = useState(false);
const [contextMenu, setContextMenu] = useState(false);
+
+ // Selectors
const hoveredObjectId = useStore((state) => state.hoveredObjectId);
const setHoveredObjectId = useStore((state) => state.setHoveredObjectId);
const setCameraTarget = useStore((state) => state.setCameraTarget);
+ const setSearchTarget = useStore((state) => state.setSearchTarget);
+ const runIntro = useStore((state) => state.runIntro); // Get runIntro state
+ const planetCamera = useStore((state) => state.planetCamera); // Get planetCamera state
+
+ const { gl } = useThree(); // Get gl to access domElement
+ const mouseDownRef = useRef(false); // Track mouse state
+
const timeoutRef = useRef(null);
- const color = !starColor ? s.color : starColor
- const circleTexture = createCircleTexture(color);
- const spriteMaterial = new SpriteMaterial({
- map: circleTexture,
- transparent: true,
- opacity: hovered ? 0.09 : 0.05,
- sizeAttenuation: false,
- // depthTest: false,
- });
+ const color = !starColor ? s.color : starColor;
+
+ // FIX 1: Only create the texture ONCE when the color changes.
+ const circleTexture = useMemo(() => {
+ return createCircleTexture(color);
+ }, [color]);
+
+ // Setup mouse listeners to track dragging
+ useEffect(() => {
+ const canvas = gl.domElement;
+
+ const onMouseDown = () => {
+ mouseDownRef.current = true;
+ };
+ const onMouseUp = () => {
+ mouseDownRef.current = false;
+ };
+
+ canvas.addEventListener("mousedown", onMouseDown);
+ canvas.addEventListener("mouseup", onMouseUp);
+
+ return () => {
+ canvas.removeEventListener("mousedown", onMouseDown);
+ canvas.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [gl]);
const handlePointerOver = () => {
+ // Abort if intro is running OR mouse is held down (dragging)
+ if (runIntro || mouseDownRef.current) return;
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
- setHover(true);
- setHoveredObjectId(s.name);
+ // Double check state before activating
+ if (!mouseDownRef.current && !runIntro) {
+ setHover(true);
+ setHoveredObjectId(s.name);
+ }
}, 200);
};
@@ -34,7 +67,12 @@ const HoverObj = ({ s, starColor=false }) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setHover(false);
if (!contextMenu) {
- setHoveredObjectId(null);
+ // Only clear if WE are the one currently hovered
+ // (This prevents clearing if the user moved quickly to another object)
+ // Although purely based on this component, we can just check our local state
+ if (hoveredObjectId === s.name) {
+ setHoveredObjectId(null);
+ }
}
};
@@ -43,16 +81,31 @@ const HoverObj = ({ s, starColor=false }) => {
return (
setCameraTarget(s.name)}
+ onDoubleClick={() => {
+ if (planetCamera) {
+ setSearchTarget(s.name);
+ } else {
+ setCameraTarget(s.name);
+ }
+ }}
onContextMenu={() => {
if (showPanel) setContextMenu(true);
}}
renderOrder={1}
>
+ {/* FIX 2: Use declarative instead of 'new SpriteMaterial()' */}
+ {/* This allows R3F to update the opacity without destroying/recreating the material */}
+
+
{
const [pinned, setPinned] = useState(false);
const setCameraTarget = useStore((state) => state.setCameraTarget);
const setHoveredObjectId = useStore((state) => state.setHoveredObjectId);
+ const planetCamera = useStore((state) => state.planetCamera); // Get planetCamera state
const groupRef = useRef(null);
@@ -58,9 +59,9 @@ const HoverPanel = ({ hovered, contextMenu, setContextMenu, s }) => {
>
{
if (pinned) {
@@ -69,7 +70,9 @@ const HoverPanel = ({ hovered, contextMenu, setContextMenu, s }) => {
setContextMenu(true);
}
}}
- onDoubleClick={() => setCameraTarget(s.name)}
+ onDoubleClick={() => {
+ if (!planetCamera) setCameraTarget(s.name);
+ }}
>
{
opacity: isVisible ? 1 : 0,
transition: `opacity ${fade}ms ease-in-out`,
fontFamily: "'Times New Roman', serif",
- fontSize: "clamp(1.5rem, 2.5vw, 2rem)",
+ fontSize: "clamp(2.5rem, 3.5vw, 3rem)",
fontWeight: "400",
textAlign: "center",
color: "grey",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
- width: `min(800px, 90vw)`,
+ width: `min(900px, 90vw)`,
padding: "0 20px",
whiteSpace: "normal",
wordWrap: "break-word",
diff --git a/src/components/Intro/IntroText.jsx b/src/components/Intro/IntroText.jsx
index d06319d..8d2aa3d 100644
--- a/src/components/Intro/IntroText.jsx
+++ b/src/components/Intro/IntroText.jsx
@@ -2,63 +2,92 @@ import React, { useRef, useState, useEffect } from "react";
import { Text3D } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useStore } from "../../store";
+import TychosLogo3D from "./TychosLogo3D";
export default function IntroText() {
const runIntro = useStore((s) => s.runIntro);
const setRunIntro = useStore((s) => s.setRunIntro);
+
const materialRef = useRef();
- const warningMaterialRef = useRef(); // New ref for the warning text material
+ const warningMaterialRef = useRef();
+ const logoMaterialRef = useRef(); // Ref for the 3D logo
- // State to track if the device is touch-enabled
const [isTouchDevice, setIsTouchDevice] = useState(false);
- // Check for touch capabilities on mount
+ // Decouple the banner's existence from runIntro so it can finish fading out
+ const [isFinished, setIsFinished] = useState(!runIntro);
+
useEffect(() => {
- // Use modern CSS media query for "coarse" pointer (standard for touchscreens)
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
- // Check for 'ontouchstart' event for broader compatibility
- const hasTouchEvents = 'ontouchstart' in window;
-
+ const hasTouchEvents = "ontouchstart" in window;
+
if (isCoarsePointer || hasTouchEvents) {
setIsTouchDevice(true);
}
}, []);
- // Animate opacity over time using the ref
+ // If the intro is ever reset/replayed, reset the banner
+ useEffect(() => {
+ if (runIntro) {
+ setIsFinished(false);
+ if (materialRef.current) materialRef.current.opacity = 1;
+ if (warningMaterialRef.current) warningMaterialRef.current.opacity = 1;
+ if (logoMaterialRef.current) logoMaterialRef.current.opacity = 1;
+ }
+ }, [runIntro]);
+
useFrame((state, delta) => {
- if (!runIntro) return;
+ if (isFinished) return;
+
+ // Only fade if opacity is significantly above 0
if (materialRef.current && materialRef.current.opacity > 0.01) {
+ // Fade twice as fast if the user clicked to interrupt (runIntro === false)
+ const fadeSpeed = runIntro ? 0.07 : 0.14;
+
const newOpacity = Math.max(
- materialRef.current.opacity - delta * 0.07,
+ materialRef.current.opacity - delta * fadeSpeed,
0
);
materialRef.current.opacity = newOpacity;
- // Apply the same fade to the warning text material if it exists
if (warningMaterialRef.current) {
warningMaterialRef.current.opacity = newOpacity;
}
+
+ if (logoMaterialRef.current) {
+ logoMaterialRef.current.opacity = newOpacity;
+ }
} else {
- // End the introduction once the text is fully faded
- setRunIntro(false);
+ // Once faded out, turn off the flag and unmount
+ setIsFinished(true);
+ // Only clear runIntro if it's still true (user might have already clicked through)
+ if (runIntro) {
+ setRunIntro(false);
+ }
}
});
- // Don't render if intro is not running
- if (!runIntro) return null;
+ if (isFinished) return null;
+
+ const titlePosition = [-140, 0, -150];
+ const warningPos = [-180, 0, -100];
- // Title position and size constants for easy reference and alignment
- const titlePosition = [-140, 0, -100];
- const warningPos = [-180, 0, -100];
+ // Adjusted to sit much closer to the left of the title
+ const logoPosition = [-125, 0, -185];
return (
<>
- {/* Main Title Text: "The TYCHOSIUM" */}
+
+
- The TYCHOSIUM
+ The Tychosium
-
- {/* Conditional Warning Text for Touch Devices, positioned below the title */}
+
{isTouchDevice && (
);
-}
\ No newline at end of file
+}
diff --git a/src/components/Intro/TychosLogo3D.jsx b/src/components/Intro/TychosLogo3D.jsx
new file mode 100644
index 0000000..a43cafb
--- /dev/null
+++ b/src/components/Intro/TychosLogo3D.jsx
@@ -0,0 +1,62 @@
+import React, { useMemo, useEffect } from "react";
+import * as THREE from "three";
+import { Torus, Sphere, Cone } from "@react-three/drei";
+
+export default function TychosLogo3D({ materialRef, ...props }) {
+ // Create ONE material instance so fading it applies to ALL parts of the logo simultaneously
+ const logoMat = useMemo(
+ () =>
+ new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ roughness: 0.2,
+ metalness: 0.5,
+ transparent: true,
+ opacity: 1,
+ }),
+ []
+ );
+
+ // Bind the shared material to the ref provided by the parent (IntroText)
+ useEffect(() => {
+ if (materialRef) {
+ materialRef.current = logoMat;
+ }
+ }, [logoMat, materialRef]);
+
+ const tubeThickness = 4;
+
+ return (
+
+ {/* --- Main Orbits --- */}
+
+
+
+
+ {/* --- Lower Intersecting Orbit (Mars Deferent) --- */}
+
+
+ {/* --- Lowest Small Orbit --- */}
+
+
+ {/* --- Solid Bodies --- */}
+
+
+
+
+ {/* --- Sun Rays (8 points) --- */}
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/Labels/NameLabelBillboard.jsx b/src/components/Labels/NameLabelBillboard.jsx
new file mode 100644
index 0000000..fee7311
--- /dev/null
+++ b/src/components/Labels/NameLabelBillboard.jsx
@@ -0,0 +1,90 @@
+import { useRef, Suspense } from "react";
+import { useFrame } from "@react-three/fiber";
+import { Text, Billboard } from "@react-three/drei";
+import { useStore } from "../../store";
+import * as THREE from "three";
+
+const worldPos = new THREE.Vector3();
+const camWorldPos = new THREE.Vector3();
+const parentScale = new THREE.Vector3();
+
+const NameLabel = ({ s }) => {
+ const showLabels = useStore((state) => state.showLabels);
+ const runIntro = useStore((state) => state.runIntro);
+ const actualPlanetSizes = useStore((state) => state.actualPlanetSizes);
+ const planetScale = useStore((state) => state.planetScale);
+ const planetCamera = useStore((state) => state.planetCamera);
+
+ const billboardRef = useRef();
+ const textRef = useRef();
+
+ const baseSize = actualPlanetSizes ? (s.actualSize || s.size) : s.size;
+
+ // Set the larger pixel sizes for Planet Camera mode
+ const PIXEL_HEIGHT = planetCamera ? 11 : 13;
+ const PIXEL_PADDING = planetCamera ? 4 : 6;
+
+ useFrame(({ camera, size }) => {
+ if (!billboardRef.current || !textRef.current) return;
+
+ billboardRef.current.getWorldPosition(worldPos);
+ camera.getWorldPosition(camWorldPos);
+ const distance = camWorldPos.distanceTo(worldPos);
+
+ const vFov = (camera.fov * Math.PI) / 180;
+ const visibleHeight = (2 * Math.tan(vFov / 2) * distance) / camera.zoom;
+ const pixelSize3D = visibleHeight / size.height;
+
+ billboardRef.current.getWorldScale(parentScale);
+ const scaleX = parentScale.x || 1;
+ const scaleY = parentScale.y || 1;
+ const scaleZ = parentScale.z || 1;
+
+ // 1. Maintain locked screen-pixel size
+ const targetScale = pixelSize3D * PIXEL_HEIGHT;
+ textRef.current.scale.set(
+ targetScale / scaleX,
+ targetScale / scaleY,
+ targetScale / scaleZ
+ );
+
+ // 2. THE CRUST LOGIC:
+ // The visual radius is ALREADY in local 3D space, so it needs NO division.
+ const localCrustRadius = baseSize * planetScale;
+
+ // The 6-pixel gap is in WORLD space, so we MUST divide it by scaleY to match local space.
+ const localPadding = (pixelSize3D * PIXEL_PADDING) / scaleY;
+
+ // 3. Stack them cleanly
+ textRef.current.position.set(0, localCrustRadius + localPadding, 0);
+ });
+
+ if (runIntro || !showLabels) return null;
+
+ return (
+
+
+
+ {s.name}
+
+
+
+ );
+};
+
+export default NameLabel;
\ No newline at end of file
diff --git a/src/components/Labels/NameLabel.jsx b/src/components/Labels/NameLabelHTML.jsx
similarity index 87%
rename from src/components/Labels/NameLabel.jsx
rename to src/components/Labels/NameLabelHTML.jsx
index 11a7413..7def70e 100644
--- a/src/components/Labels/NameLabel.jsx
+++ b/src/components/Labels/NameLabelHTML.jsx
@@ -16,6 +16,7 @@ const NameLabel = ({ s }) => {
{
);
};
-export default NameLabel;
+export default NameLabel;
\ No newline at end of file
diff --git a/src/components/LevaUI.jsx b/src/components/LevaUI.jsx
index 81299c0..06e070d 100644
--- a/src/components/LevaUI.jsx
+++ b/src/components/LevaUI.jsx
@@ -14,8 +14,6 @@ const LevaUI = () => {
setCameraFollow,
planetCamera,
setPlanetCamera,
- planetCameraHelper,
- setPlanetCameraHelper,
orbits,
setOrbits,
arrows,
@@ -32,6 +30,8 @@ const LevaUI = () => {
setZodiac,
zodiacSize,
setZodiacSize,
+ tropicalZodiac,
+ setTropicalZodiac,
polarLine,
setPolarLine,
polarLineSize,
@@ -60,6 +60,8 @@ const LevaUI = () => {
setGeoSphere,
ephimerides,
setEphemerides,
+ plot,
+ setPlot,
BSCStars,
setBSCStars,
hScale,
@@ -123,10 +125,7 @@ const LevaUI = () => {
value: planetCamera,
onChange: setPlanetCamera,
},
- // "Show planet camera position": {
- // value: planetCameraHelper,
- // onChange: setPlanetCameraHelper,
- // },
+
"Camera follow": { value: cameraFollow, onChange: setCameraFollow },
Labels: {
value: showLabels,
@@ -136,7 +135,7 @@ const LevaUI = () => {
value: orbits,
onChange: setOrbits,
},
- "Search stars": {
+ Search: {
value: searchStars,
onChange: setSearchStars,
},
@@ -149,11 +148,17 @@ const LevaUI = () => {
value: ephimerides,
onChange: setEphemerides,
},
+ // Plot: {
+ // value: plot,
+ // onChange: (v) => {
+ // setPlot(v);
+ // },
+ // },
},
{ collapsed: false }
),
- "Trace": folder(
+ Trace: folder(
{
TraceOnOff: {
label: "Trace On/Off",
@@ -229,9 +234,19 @@ const LevaUI = () => {
"Stars & Helpers": folder(
{
// Moved to the top of "Stars & Helpers"
- "BSC Stars": {
+ Stars: {
+ // Changed from "BSC Stars"
value: BSCStars,
- onChange: setBSCStars,
+ onChange: (v) => {
+ setBSCStars(v); // Toggle stars visibility
+
+ // Explicitly turn off the Search menu state if Stars are unchecked
+ if (!v) {
+ setSearchStars(false);
+ } else {
+ setSearchStars(true);
+ }
+ },
},
// "Use star distances": {
// value: officialStarDistances,
@@ -244,22 +259,23 @@ const LevaUI = () => {
step: 100,
onChange: setStarDistanceModifier,
},
- "Equidistant stars": {
+ //Renamed equdistant stars to Celestial sphere in the meny. Easier to understand.
+ "Celestial sphere": {
value: false,
min: 1,
step: 100,
onChange: setEquidistantStars,
},
// Added Constellations here
- "Constellations": {
+ Constellations: {
value: showConstellations,
onChange: setShowConstellations,
},
- "Celestial sphere": {
- value: celestialSphere,
- onChange: setCelestialSphere,
- },
- "Ecliptic grid": {
+ // "Celestial grid": {
+ // value: celestialSphere,
+ // onChange: setCelestialSphere,
+ // },
+ "Equinoxes & Solistices": {
value: eclipticGrid,
onChange: setEclipticGrid,
},
@@ -267,7 +283,11 @@ const LevaUI = () => {
value: zodiac,
onChange: setZodiac,
},
- "Sphere/Grid/Zodiac size": {
+ "Tropical Zodiac": {
+ value: tropicalZodiac,
+ onChange: setTropicalZodiac,
+ },
+ "Sphere & Zodiac size": {
value: hScale,
min: 0.5,
max: 100,
@@ -277,7 +297,7 @@ const LevaUI = () => {
},
{ collapsed: true }
),
- "Light & Effects": folder(
+ Settings: folder(
{},
//Populated in LightEffectsMenu
{ collapsed: true }
@@ -289,8 +309,18 @@ const LevaUI = () => {
set2({
"Actual planet sizes": actualPlanetSizes,
Orbits: orbits,
+ Ephemerides: ephimerides,
+ Positions: showPositions,
+ Search: searchStars,
});
- }, [actualPlanetSizes, orbits, set2]);
+ }, [
+ actualPlanetSizes,
+ orbits,
+ ephimerides,
+ showPositions,
+ searchStars,
+ set2,
+ ]);
const prevTransitioningRef = useRef(false);
useEffect(() => {
@@ -329,8 +359,11 @@ const LevaUI = () => {
titleBar={false}
hideCopyButton
theme={{
+ sizes: {
+ controlWidth: "40%",
+ },
fontSizes: {
- root: "16px",
+ root: "12px",
},
fonts: {
mono: "",
@@ -349,8 +382,11 @@ const LevaUI = () => {
titleBar={false}
hideCopyButton
theme={{
+ sizes: {
+ controlWidth: "40%",
+ },
fontSizes: {
- root: "16px",
+ root: "12px",
},
fonts: {
mono: "",
@@ -363,4 +399,4 @@ const LevaUI = () => {
);
};
-export default LevaUI;
\ No newline at end of file
+export default LevaUI;
diff --git a/src/components/Menus/EditSettings.jsx b/src/components/Menus/EditSettings.jsx
index c318e4e..a51cd6a 100644
--- a/src/components/Menus/EditSettings.jsx
+++ b/src/components/Menus/EditSettings.jsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
+import { createPortal } from "react-dom";
import { useControls, useCreateStore, Leva, folder, button } from "leva";
import { useStore, useSettingsStore, usePosStore } from "../../store";
import {
@@ -96,6 +97,7 @@ const EditSettings = () => {
onChange: (value) => {
const cleanValue = value.replace(/\u200B/g, "");
s.rotationStart = cleanValue;
+ // console.log(s.rotationStart);
updateSetting({
...s,
rotationStart: cleanValue,
@@ -277,36 +279,43 @@ const EditSettings = () => {
set(updatedValues);
}, [settings, set]);
- return (
- <>
- {editSettings && (
-
-
-
- )}
- >
+ return createPortal(
+
+
+
,
+ document.body
);
};
diff --git a/src/components/Menus/LightEffectsMenu.jsx b/src/components/Menus/LightEffectsMenu.jsx
index 4b86372..3edd7c0 100644
--- a/src/components/Menus/LightEffectsMenu.jsx
+++ b/src/components/Menus/LightEffectsMenu.jsx
@@ -1,6 +1,8 @@
// LightEffectsMenu.js
import { useEffect } from "react";
import { Stats } from "@react-three/drei";
+import { Perf } from "r3f-perf";
+
import {
EffectComposer,
Bloom,
@@ -13,8 +15,19 @@ import { useStore } from "../../store";
import { usePlanetCameraStore } from "../PlanetCamera/planetCameraStore";
const LightEffectsMenu = () => {
+ const zoomLevel = useStore((state) => state.zoomLevel);
+ const setZoom = useStore((state) => state.setZoom);
+ const { runIntro, setRunIntro } = useStore();
+
const { ambientLight, glow, glowIntensity, antialiasing, stats } =
- useControls("Light & Effects", {
+ useControls("Settings", {
+ "UI & Labels size": {
+ value: zoomLevel,
+ min: 60,
+ max: 120,
+ step: 1,
+ onChange: (v) => setZoom(v),
+ },
ambientLight: {
label: "Ambient light",
value: 1,
@@ -30,6 +43,7 @@ const LightEffectsMenu = () => {
step: 0.1,
onChange: (v) => useStore.setState({ sunLight: v }),
},
+
glow: {
label: "Glow",
value: true,
@@ -37,23 +51,24 @@ const LightEffectsMenu = () => {
},
glowIntensity: {
label: "Glow strength",
- value: 0.5,
+ value: 0.2,
min: 0.1,
max: 2,
step: 0.1,
hint: "Glow can affect performance",
},
- antialiasing: {
- label: "Anti-Aliasing",
- value: "SMAA",
- options: ["FXAA", "SMAA", "None"],
- },
- stats: {
- value: false,
- label: "Show FPS",
- },
- Settings: folder(
+
+ "experimental settings": folder(
{
+ antialiasing: {
+ label: "Anti-Aliasing",
+ value: "SMAA",
+ options: ["FXAA", "SMAA", "None"],
+ },
+ stats: {
+ value: false,
+ label: "Show FPS",
+ },
"Star sizes": {
value: useStore.getState().starScale,
min: 0.1,
@@ -97,8 +112,20 @@ const LightEffectsMenu = () => {
step: 0.1,
onChange: (v) => usePlanetCameraStore.setState({ groundHeight: v }),
},
+ "Show planet camera position": {
+ value: useStore.getState().planetCameraHelper,
+ onChange: (v) => useStore.setState({ planetCameraHelper: v }),
+ },
+ "Show Intro": {
+ value: runIntro,
+ onChange: (v) => setRunIntro(v),
+ },
+ "Video Recorder": {
+ value: useStore.getState().showRecorder,
+ onChange: (v) => useStore.setState({ showRecorder: v }),
+ },
},
- { collapsed: true }
+ { collapsed: false }
),
});
@@ -111,7 +138,18 @@ const LightEffectsMenu = () => {
{antialiasing === "SMAA" &&
}
{glow &&
}
- {stats &&
}
+ {stats && (
+
+ )}
>
);
};
diff --git a/src/components/Menus/Positions.jsx b/src/components/Menus/Positions.jsx
index 3c05230..b4c9e41 100644
--- a/src/components/Menus/Positions.jsx
+++ b/src/components/Menus/Positions.jsx
@@ -1,9 +1,11 @@
import { useEffect, useMemo } from "react";
+import { createPortal } from "react-dom";
import { useControls, useCreateStore, Leva, folder } from "leva";
import { useStore, useSettingsStore, usePosStore } from "../../store";
const Positions = () => {
const showPositions = useStore((s) => s.showPositions);
+ const setShowPositions = useStore((s) => s.setShowPositions);
const positions = usePosStore((s) => s.positions);
const { settings } = useSettingsStore();
@@ -15,7 +17,6 @@ const Positions = () => {
if (s.traceable) {
folders[s.name] = folder(
{
- // Use unique keys for each control
[`${s.name}ra`]: { label: "RA:", value: "", editable: false },
[`${s.name}dec`]: { label: "Dec:", value: "", editable: false },
[`${s.name}dist`]: {
@@ -41,12 +42,71 @@ const Positions = () => {
};
return folders;
- }, [settings]); // Only recreate if `settings` changes
+ }, [settings]);
// Create a custom Leva store
const levaStore = useCreateStore();
- // Set up Leva controls (only runs once)
+ // Highly targeted DOM injection for the X button
+ useEffect(() => {
+ if (!showPositions) return;
+
+ const interval = setInterval(() => {
+ // Find the deepest div containing ONLY the exact title text
+ const textDiv = Array.from(document.querySelectorAll("div")).find(
+ (el) =>
+ el.textContent.trim() === "Positions" && el.children.length === 0
+ );
+
+ if (textDiv) {
+ // Leva's title bar is the immediate flex container wrapping this text
+ const titleBar = textDiv.parentElement;
+
+ if (titleBar && !titleBar.querySelector(".leva-close-x")) {
+ // Allow the title bar to anchor our absolutely positioned button
+ titleBar.style.position = "relative";
+
+ const closeBtn = document.createElement("div");
+ closeBtn.className = "leva-close-x";
+ closeBtn.innerHTML = "✕";
+
+ // Style it seamlessly into the top right corner
+ Object.assign(closeBtn.style, {
+ position: "absolute",
+ right: "12px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ cursor: "pointer",
+ color: "#8C92A4",
+ fontSize: "14px",
+ fontWeight: "bold",
+ padding: "4px",
+ zIndex: "9999",
+ });
+
+ // Native hover colors
+ closeBtn.onmouseenter = () => (closeBtn.style.color = "#FFFFFF");
+ closeBtn.onmouseleave = () => (closeBtn.style.color = "#8C92A4");
+
+ // CRITICAL: Stop the click from passing through and triggering Leva's drag feature
+ closeBtn.onmousedown = (e) => e.stopPropagation();
+
+ // Close action
+ closeBtn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowPositions(false);
+ };
+
+ titleBar.appendChild(closeBtn);
+ }
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, [showPositions, setShowPositions]);
+
+ // Set up Leva controls for planets (removed the old manual button)
const [, set] = useControls(() => planetFolders, { store: levaStore });
// Update Leva controls when `positions` change
@@ -59,33 +119,40 @@ const Positions = () => {
[`${pos}elongation`]: positions[pos].elongation,
});
}
- }, [JSON.stringify(positions), set]); // Serialize to detect deep changes
-
- return (
- <>
- {showPositions && (
-
-
-
- )}
- >
+ }, [JSON.stringify(positions), set]);
+
+ if (!showPositions) return null;
+
+ return createPortal(
+
+
+
,
+ document.body
);
};
diff --git a/src/components/Menus/RecorderMenu.jsx b/src/components/Menus/RecorderMenu.jsx
new file mode 100644
index 0000000..7c94fbb
--- /dev/null
+++ b/src/components/Menus/RecorderMenu.jsx
@@ -0,0 +1,131 @@
+import { useEffect } from "react";
+import { createPortal } from "react-dom";
+import { useControls, useCreateStore, Leva, button } from "leva";
+import { useStore } from "../../store";
+import { useRecorderStore } from "../Recorder/recorderStore";
+
+const RecorderMenu = () => {
+ const showRecorder = useStore((s) => s.showRecorder);
+ const setShowRecorder = useStore((s) => s.setShowRecorder);
+
+ const status = useRecorderStore((s) => s.status);
+ const progress = useRecorderStore((s) => s.progress);
+ const errorMsg = useRecorderStore((s) => s.errorMsg);
+ const setCommand = useRecorderStore((s) => s.setCommand);
+ const duration = useRecorderStore((s) => s.duration);
+ const setDuration = useRecorderStore((s) => s.setDuration);
+ const sizePreset = useRecorderStore((s) => s.sizePreset);
+ const setSizePreset = useRecorderStore((s) => s.setSizePreset);
+
+ const levaRecorderStore = useCreateStore();
+
+ // Custom close button injection
+ useEffect(() => {
+ if (!showRecorder) return;
+ const interval = setInterval(() => {
+ const textDiv = Array.from(document.querySelectorAll("div")).find(
+ (el) =>
+ el.textContent.trim() === "Video Recorder" && el.children.length === 0
+ );
+ if (textDiv) {
+ const titleBar = textDiv.parentElement;
+ if (titleBar && !titleBar.querySelector(".leva-close-x")) {
+ titleBar.style.position = "relative";
+ const closeBtn = document.createElement("div");
+ closeBtn.className = "leva-close-x";
+ closeBtn.innerHTML = "✕";
+ Object.assign(closeBtn.style, {
+ position: "absolute",
+ right: "12px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ cursor: "pointer",
+ color: "#8C92A4",
+ fontSize: "14px",
+ fontWeight: "bold",
+ padding: "4px",
+ zIndex: "9999",
+ });
+ closeBtn.onmouseenter = () => (closeBtn.style.color = "#FFFFFF");
+ closeBtn.onmouseleave = () => (closeBtn.style.color = "#8C92A4");
+ closeBtn.onmousedown = (e) => e.stopPropagation();
+ closeBtn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowRecorder(false);
+ };
+ titleBar.appendChild(closeBtn);
+ }
+ }
+ }, 150);
+ return () => clearInterval(interval);
+ }, [showRecorder, setShowRecorder]);
+
+ // 1. Initialize Leva controls and extract the SET function
+ const [, setLeva] = useControls(
+ () => ({
+ Status: { value: "⚪ Ready", editable: false },
+ "Error Log": { value: "None", editable: false },
+ "Duration (s)": {
+ value: duration,
+ min: 1,
+ max: 60,
+ step: 1,
+ onChange: (v) => setDuration(v),
+ },
+ Resolution: {
+ options: ["1x", "2x", "3x", "4x"],
+ value: sizePreset,
+ onChange: (v) => setSizePreset(v),
+ },
+ Start: button(() => setCommand("start")),
+ Cancel: button(() => setCommand("cancel")),
+ }),
+ { store: levaRecorderStore }
+ );
+
+ // 2. FORCE push the Zustand state into the Leva UI whenever it changes
+ useEffect(() => {
+ let displayStatus = "⚪ Ready";
+ if (status === "Initializing") displayStatus = "⏳ Resizing Canvas...";
+ else if (status === "Recording")
+ displayStatus = `🔴 RECORDING (${progress}%)`;
+ else if (status === "Finalizing") displayStatus = "💾 Finalizing MP4...";
+ else if (status === "Error") displayStatus = "❌ ERROR";
+
+ // This is the magic command that actually updates the on-screen text
+ setLeva({
+ Status: displayStatus,
+ "Error Log": errorMsg || "None",
+ });
+ }, [status, progress, errorMsg, setLeva]);
+
+ if (!showRecorder) return null;
+
+ return createPortal(
+
+
+
,
+ document.body
+ );
+};
+
+export default RecorderMenu;
diff --git a/src/components/Menus/ephemeridesStore.js b/src/components/Menus/ephemeridesStore.js
deleted file mode 100644
index 9febb6f..0000000
--- a/src/components/Menus/ephemeridesStore.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { create } from "zustand";
-
-export const useEphemeridesStore = create((set) => ({
- trigger: false,
- params: null,
- setGenerationParams: (params) => set({ trigger: true, params }),
- resetTrigger: () => set({ trigger: false }),
-}));
diff --git a/src/components/Orbit.jsx b/src/components/Orbit.jsx
index e084ebc..0f7f119 100644
--- a/src/components/Orbit.jsx
+++ b/src/components/Orbit.jsx
@@ -1,4 +1,4 @@
-import { useRef } from "react";
+import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useStore } from "../store";
import { Line } from "@react-three/drei";
@@ -29,34 +29,53 @@ export default function Orbit({ radius, visible, s }) {
const arrows = s?.arrows ? s.arrows : false;
const reverse = s?.reverseArrows ? s.reverseArrows : false;
-
const orbitRef = useRef();
const showArrows = useStore((s) => s.arrows);
const showOrbits = useStore((s) => s.orbits);
const orbitsLineWidth = useStore((s) => s.orbitsLineWidth);
- let points = [];
- let arrowPoints = [];
- let arrowStepSize = 45;
+ // let points = [];
+ // let arrowPoints = [];
+ // let arrowStepSize = 45;
+
+ // // // 360 full circle will be drawn clockwise
+ // // for (let i = 0; i <= 360; i++) {
+ // for (let i = 0; i <= 360; i = i + 0.1) {
+ // points.push([
+ // Math.sin(i * (Math.PI / 180)) * radius,
+ // Math.cos(i * (Math.PI / 180)) * radius,
+ // 0,
+ // ]);
+ // if (i === arrowStepSize) {
+ // arrowPoints.push([
+ // Math.sin(i * (Math.PI / 180)) * radius,
+ // Math.cos(i * (Math.PI / 180)) * radius,
+ // 0,
+ // ]);
+ // arrowStepSize += arrowStepSize;
+ // }
+ // }
+
+ const { points, arrowPoints } = useMemo(() => {
+ const pts = [];
+ const aPts = [];
+ let nextArrowStep = 45;
- // // 360 full circle will be drawn clockwise
- // for (let i = 0; i <= 360; i++) {
- for (let i = 0; i <= 360; i = i + 0.1) {
- points.push([
- Math.sin(i * (Math.PI / 180)) * radius,
- Math.cos(i * (Math.PI / 180)) * radius,
- 0,
- ]);
- if (i === arrowStepSize) {
- arrowPoints.push([
- Math.sin(i * (Math.PI / 180)) * radius,
- Math.cos(i * (Math.PI / 180)) * radius,
- 0,
- ]);
- arrowStepSize += arrowStepSize;
+ for (let i = 0; i <= 360; i += 0.1) {
+ const rad = i * (Math.PI / 180);
+ const x = Math.sin(rad) * radius;
+ const y = Math.cos(rad) * radius;
+ pts.push([x, y, 0]);
+
+ // Fixed step logic (previously it was 45, 90, 180, 360)
+ if (i >= nextArrowStep && nextArrowStep <= 315) {
+ aPts.push([x, y, 0]);
+ nextArrowStep += 90; // Add arrows every 90 degrees
+ }
}
- }
+ return { points: pts, arrowPoints: aPts };
+ }, [radius]);
return (
<>
@@ -93,6 +112,7 @@ export default function Orbit({ radius, visible, s }) {
color={color} // Default
lineWidth={orbitsLineWidth} // In pixels (default)
dashed={false}
+ raycast={() => null}
/>
>
diff --git a/src/components/OrbitCamera.jsx b/src/components/OrbitCamera.jsx
index 8d5e6d3..5544531 100644
--- a/src/components/OrbitCamera.jsx
+++ b/src/components/OrbitCamera.jsx
@@ -1,4 +1,4 @@
-import { useRef, useLayoutEffect, useEffect } from "react";
+import { useRef, useLayoutEffect, useEffect, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Vector3 } from "three";
import { PerspectiveCamera, CameraControls } from "@react-three/drei";
@@ -9,6 +9,7 @@ export default function OrbitCamera() {
const { scene, camera } = useThree();
const cameraRef = useRef();
const controlsRef = useRef();
+
const planetCamera = useStore((s) => s.planetCamera);
const cameraTarget = useStore((s) => s.cameraTarget);
const cameraFollow = useStore((s) => s.cameraFollow);
@@ -16,13 +17,14 @@ export default function OrbitCamera() {
const resetClicked = useStore((s) => s.resetClicked);
const runIntro = useStore((s) => s.runIntro);
const setRunIntro = useStore((s) => s.setRunIntro);
+ const setCameraControlsRef = useStore((s) => s.setCameraControlsRef);
+ const cameraTransitioning = useStore((s) => s.cameraTransitioning);
+ const actualPlanetSizes = useStore((s) => s.actualPlanetSizes);
const targetObjRef = useRef(null);
- const target = new Vector3();
- const setCameraControlsRef = useStore((s) => s.setCameraControlsRef);
-
- const cameraTransitioning = useStore((s) => s.cameraTransitioning);
+ // PERFORMANCE FIX: Persist Vector3 to prevent garbage collection micro-stutters
+ const target = useMemo(() => new Vector3(), []);
useEffect(() => {
if (controlsRef.current) {
@@ -31,40 +33,39 @@ export default function OrbitCamera() {
}, [controlsRef.current, setCameraControlsRef]);
useEffect(() => {
- // Event handler function for mousedown
const handleMouseDown = (event) => {
- // Check if it's a left mouse button (button === 0)
if (event.button === 0) {
setRunIntro(false);
}
};
- // Add event listener to the document for mousedown
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("wheel", handleMouseDown);
- // Cleanup function
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("wheel", handleMouseDown);
};
- }, []);
+ }, [setRunIntro]);
useLayoutEffect(() => {
targetObjRef.current = scene.getObjectByName(cameraTarget);
- targetObjRef.current.getWorldPosition(target);
- controlsRef.current.setTarget(target.x, target.y, target.z, false);
- }, [cameraTarget, cameraUpdate, camera]);
+ if (targetObjRef.current) {
+ targetObjRef.current.getWorldPosition(target);
+ // TRANSITION FIX: 'true' enables smooth interpolation to the new target
+ controlsRef.current.setTarget(target.x, target.y, target.z, true);
+ }
+ }, [cameraTarget, cameraUpdate, camera, scene, target]);
useEffect(() => {
if (controlsRef.current && !runIntro) {
- controlsRef.current.setPosition(0, 2200, 0);
+ // TRANSITION FIX: 'true' enables smooth return to the default position
+ controlsRef.current.setPosition(0, 2200, 0, true);
}
}, [resetClicked, runIntro]);
useEffect(() => {
if (!planetCamera) {
- // Ensure all planets are visible when returning to orbit view
const { settings } = useSettingsStore.getState();
settings.forEach((setting) => {
if (setting.planetCamera === true) {
@@ -76,12 +77,19 @@ export default function OrbitCamera() {
}
});
}
- }, [planetCamera]);
+ }, [planetCamera, scene]);
useFrame(() => {
if (cameraFollow) {
- targetObjRef.current.getWorldPosition(target);
- controlsRef.current.setTarget(target.x, target.y, target.z, false);
+ if (targetObjRef.current) {
+ // PERF/SYNC FIX: Force world matrix update to prevent 1-frame tracking lag
+ targetObjRef.current.updateWorldMatrix(true, false);
+
+ targetObjRef.current.getWorldPosition(target);
+
+ // Kept false: Interpolating every frame during tracking causes lag
+ controlsRef.current.setTarget(target.x, target.y, target.z, false);
+ }
}
});
@@ -92,13 +100,21 @@ export default function OrbitCamera() {
name="OrbitCamera"
ref={cameraRef}
position={[-30000000, 10000000, 0]}
- // position={[0, 2200, 0]}
- // position={[-3000, 1000, 0]}
fov={15}
near={0.0001}
far={10000000000000}
/>
-
+
{runIntro &&
}
>
);
diff --git a/src/components/Planet.jsx b/src/components/Planet.jsx
index 58a93c2..bc9e973 100644
--- a/src/components/Planet.jsx
+++ b/src/components/Planet.jsx
@@ -1,19 +1,26 @@
import { useFrame } from "@react-three/fiber";
-import { useRef, useEffect, memo } from "react";
+import { useRef, useEffect, memo, useMemo } from "react";
+import * as THREE from "three";
import { useStore } from "../store";
import { usePlanetCameraStore } from "./PlanetCamera/planetCameraStore";
import useTextureLoader from "../utils/useTextureLoader";
import CelestialSphere from "./Helpers/CelestialSphere";
import PolarLine from "./Helpers/PolarLine";
+import TropicalZodiac from "./Helpers/TropicalZodiac";
import HoverObj from "../components/HoverObj/HoverObj";
import PlanetRings from "./PlanetRings";
-import NameLabel from "./Labels/NameLabel";
+import NameLabel from "./Labels/NameLabelBillboard";
import GeoSphere from "./Helpers/GeoSphere";
+// Define reusable vector outside to prevent GC pressure
+const worldPositionVec = new THREE.Vector3();
+
const Planet = memo(function Planet({ s, actualMoon, name }) {
- const planetRef = useRef(); // Group for rotation and scaling
+ const planetRef = useRef();
+ const transformRef = useRef(); // New ref for the scale group
const pivotRef = useRef();
const materialRef = useRef();
+
const posRef = useStore((state) => state.posRef);
const sunLight = useStore((state) => state.sunLight);
const planetScale = useStore((state) => state.planetScale);
@@ -23,7 +30,6 @@ const Planet = memo(function Planet({ s, actualMoon, name }) {
const planetCameraTarget = usePlanetCameraStore(
(state) => state.planetCameraTarget
);
-
const cameraTransitioning = useStore((s) => s.cameraTransitioning);
const { texture, isLoading } = s.texture
@@ -40,8 +46,8 @@ const Planet = memo(function Planet({ s, actualMoon, name }) {
}
}, [texture, s.light]);
- const rotationSpeed = s.rotationSpeed || 0;
- const rotationStart = s.rotationStart || 0;
+ const rotationSpeed = Number(s.rotationSpeed || 0);
+ const rotationStart = Number(s.rotationStart || 0);
let size = actualPlanetSizes ? s.actualSize : s.size;
let visible = s.visible;
@@ -52,72 +58,82 @@ const Planet = memo(function Planet({ s, actualMoon, name }) {
useFrame(() => {
if (s.fixedTilt && pivotRef.current) {
- //Adjust the tilt so that it's fixed in respect to the orbit
pivotRef.current.rotation.y = -(
s.speed * posRef.current -
s.startPos * (Math.PI / 180)
);
}
- if (rotationSpeed && planetRef.current) {
- // Rotate the group containing the planet
+ if (planetRef.current) {
planetRef.current.rotation.y =
rotationStart + rotationSpeed * posRef.current;
}
});
- const tilt = s.tilt || 0;
- const tiltb = s.tiltb || 0;
+ const tilt = Number(s.tilt || 0);
+ const tiltb = Number(s.tiltb || 0);
- // Hide label and hoverObj if this planet is the active planet camera target
const showLabel =
visible &&
!(planetCamera && !cameraTransitioning && name === planetCameraTarget);
+ // Updated Geometry logic: 256 for Earth, 128 otherwise
+ const planetGeometry = useMemo(() => {
+ const segments = s.name === "Earth" ? 256 : 128;
+ return
;
+ }, [size, s.name]);
+
return (
-
- {s.name === "Earth" && }
- {(s.name === "Earth" || s.name === "Sun") && (
-
- )}
- {showLabel && }
- {showLabel && }
-
-
-
-
- {s.light && }
-
- {s.geoSphere && geoSphere ? (
-
- ) : null}
- {s.rings && (
-
+
+ {s.name === "Earth" && }
+
+ {s.name === "Earth" && }
+
+ {(s.name === "Earth" || s.name === "Sun") && (
+
)}
+ {showLabel && }
+ {showLabel && }
+
+
+
+ {planetGeometry}
+
+ {s.light && }
+
+ {s.geoSphere && geoSphere ? (
+
+ ) : null}
+ {s.rings && (
+
+ )}
+
);
diff --git a/src/components/PlanetCamera/FocusSearchedStar.jsx b/src/components/PlanetCamera/FocusSearchedStar.jsx
index 36d0ef4..d98a824 100644
--- a/src/components/PlanetCamera/FocusSearchedStar.jsx
+++ b/src/components/PlanetCamera/FocusSearchedStar.jsx
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react";
-import { useFrame } from "@react-three/fiber";
+import { useFrame, useThree } from "@react-three/fiber";
+import { Vector3, Spherical } from "three";
import { useStore } from "../../store";
import { usePlanetCameraStore } from "./planetCameraStore";
import starsData from "../../settings/BSC.json";
+import specialStarsData from "../../settings/star-settings.json";
import { raDecToAltAz } from "../../utils/celestial-functions";
import { posToDate, posToTime } from "../../utils/time-date-functions";
import TWEEN from "@tweenjs/tween.js";
@@ -12,6 +14,7 @@ export default function FocusSearchedStar() {
const planetCamera = useStore((s) => s.planetCamera);
const posRef = useStore((s) => s.posRef);
const tweenRef = useRef(null);
+ const { scene } = useThree();
useEffect(() => {
if (!selectedStarHR || !planetCamera) return;
@@ -19,61 +22,144 @@ export default function FocusSearchedStar() {
const planetCameraTarget =
usePlanetCameraStore.getState().planetCameraTarget;
- // Only focus stars when on Earth
+ // Only focus stars/objects when on Earth
if (planetCameraTarget !== "Earth") return;
- const star = starsData.find((s) => s.HR === selectedStarHR);
- if (!star || !star.RA || !star.Dec) return;
-
- const raMatch = star.RA.match(/(\d+)h\s*(\d+)m\s*([\d.]+)s/);
- const decMatch = star.Dec.match(/([+-]?\d+)°\s*(\d+)′\s*([\d.]+)″/);
-
- if (!raMatch || !decMatch) return;
-
- const raHours =
- parseInt(raMatch[1]) +
- parseInt(raMatch[2]) / 60 +
- parseFloat(raMatch[3]) / 3600;
-
- const decSign = star.Dec.startsWith("-") ? -1 : 1;
- const decDegrees =
- decSign *
- (Math.abs(parseInt(decMatch[1])) +
- parseInt(decMatch[2]) / 60 +
- parseFloat(decMatch[3]) / 3600);
-
- const planCamLat = usePlanetCameraStore.getState().planCamLat;
- const planCamLong = usePlanetCameraStore.getState().planCamLong;
- const currentAngle = usePlanetCameraStore.getState().planCamAngle;
- const currentDirection = usePlanetCameraStore.getState().planCamDirection;
-
- const currentDate = posToDate(posRef.current);
- const currentTime = posToTime(posRef.current);
- const dateTime = `${currentDate}T${currentTime}Z`;
-
- const { altitude, azimuth } = raDecToAltAz(
- raHours,
- decDegrees,
- planCamLat,
- planCamLong,
- dateTime
- );
-
- // Tween to target angles
- const coords = { angle: currentAngle, direction: currentDirection };
- const setPlanCamAngle = usePlanetCameraStore.getState().setPlanCamAngle;
- const setPlanCamDirection =
- usePlanetCameraStore.getState().setPlanCamDirection;
-
- tweenRef.current = new TWEEN.Tween(coords)
- .to({ angle: altitude, direction: azimuth }, 2000)
- .easing(TWEEN.Easing.Quadratic.Out)
- .onUpdate(() => {
- setPlanCamAngle(coords.angle);
- setPlanCamDirection(coords.direction);
- })
- .start();
- }, [selectedStarHR, planetCamera, posRef]);
+ let raHours = null;
+ let decDegrees = null;
+ let targetObjectName = null;
+
+ // --- 1. Identify Scene Objects (Planets & Special Stars) ---
+ if (selectedStarHR.startsWith("Planet:")) {
+ targetObjectName = selectedStarHR.replace("Planet:", "");
+ } else if (selectedStarHR.startsWith("Special:")) {
+ targetObjectName = selectedStarHR.replace("Special:", "");
+ } else {
+ // Fallback: Check if the HR belongs to a special star (which is an object in the scene)
+ const specialStar = specialStarsData.find(
+ (s) => s.HR && String(s.HR) === selectedStarHR
+ );
+ if (specialStar) {
+ targetObjectName = specialStar.name;
+ }
+ }
+
+ // --- 2. Extract Coordinates ---
+ if (targetObjectName) {
+ // It's a 3D object (Planet or Special Star)
+ const targetObj = scene.getObjectByName(targetObjectName);
+ const celestialSphere = scene.getObjectByName("CelestialSphere");
+ const csLookAtObj = scene.getObjectByName("CSLookAtObj");
+
+ if (targetObj && celestialSphere && csLookAtObj) {
+ // Calculate RA/Dec dynamically based on current positions
+ const targetPos = new Vector3();
+ const csPos = new Vector3();
+ targetObj.getWorldPosition(targetPos);
+ celestialSphere.getWorldPosition(csPos);
+
+ // Orient the helper to look at the target
+ csLookAtObj.lookAt(targetPos);
+
+ // Get direction vector in celestial coordinates
+ const lookAtDir = new Vector3(0, 0, 1);
+ lookAtDir.applyQuaternion(csLookAtObj.quaternion);
+
+ // Convert to Spherical
+ const spherical = new Spherical();
+ spherical.setFromVector3(lookAtDir);
+
+ // Convert Spherical to RA (Hours) and Dec (Degrees)
+ let rad = spherical.theta;
+ if (rad < 0) rad += Math.PI * 2;
+ raHours = (rad * 12) / Math.PI;
+
+ // Dec = 90 - (phi * 180 / PI)
+ const phiDeg = (spherical.phi * 180) / Math.PI;
+ decDegrees = 90 - phiDeg;
+ }
+ } else {
+ // It's not a scene object, handle as a standard BSC Star
+ const star = starsData.find(
+ (s) => s.HR && String(s.HR) === selectedStarHR
+ );
+
+ if (star) {
+ const raRaw = star.RA;
+ const decRaw = star.Dec;
+
+ if (raRaw && decRaw) {
+ // Parse RA (Format: "14h 29m42.9s" or "14h 29m 42.9s")
+ const raMatch = raRaw.match(/(\d+)h\s*(\d+)m\s*([\d.]+)s/);
+
+ // Parse Dec (Format: "-62°40'46.1" or "+04° 41' 34.0″")
+ const decMatch = decRaw.match(
+ /([+-]?\d+)°\s*(\d+)['′]\s*([\d.]+)(?:["″])?/
+ );
+
+ if (raMatch && decMatch) {
+ raHours =
+ parseInt(raMatch[1]) +
+ parseInt(raMatch[2]) / 60 +
+ parseFloat(raMatch[3]) / 3600;
+
+ const decSign = decRaw.startsWith("-") ? -1 : 1;
+ const degVal = Math.abs(parseInt(decMatch[1]));
+
+ decDegrees =
+ decSign *
+ (degVal +
+ parseInt(decMatch[2]) / 60 +
+ parseFloat(decMatch[3]) / 3600);
+ }
+ }
+ }
+ }
+
+ // --- 3. Execute Move ---
+ if (raHours !== null && decDegrees !== null) {
+ const planCamLat = usePlanetCameraStore.getState().planCamLat;
+ const planCamLong = usePlanetCameraStore.getState().planCamLong;
+ const currentAngle = usePlanetCameraStore.getState().planCamAngle;
+ const currentDirection = usePlanetCameraStore.getState().planCamDirection;
+
+ const currentDate = posToDate(posRef.current);
+ const currentTime = posToTime(posRef.current);
+ const dateTime = `${currentDate}T${currentTime}Z`;
+
+ const { altitude, azimuth } = raDecToAltAz(
+ raHours,
+ decDegrees,
+ planCamLat,
+ planCamLong,
+ dateTime
+ );
+
+ // Calculate shortest path for Azimuth (Direction) to prevent 360 degree spins
+ let diffAz = (azimuth - currentDirection) % 360;
+ if (diffAz < -180) diffAz += 360;
+ if (diffAz > 180) diffAz -= 360;
+ const targetAzimuth = currentDirection + diffAz;
+
+ // Tween to target angles
+ const coords = { angle: currentAngle, direction: currentDirection };
+ const setPlanCamAngle = usePlanetCameraStore.getState().setPlanCamAngle;
+ const setPlanCamDirection =
+ usePlanetCameraStore.getState().setPlanCamDirection;
+
+ // Stop any existing tween
+ if (tweenRef.current) tweenRef.current.stop();
+
+ tweenRef.current = new TWEEN.Tween(coords)
+ .to({ angle: altitude, direction: targetAzimuth }, 2000)
+ .easing(TWEEN.Easing.Quadratic.Out)
+ .onUpdate(() => {
+ setPlanCamAngle(coords.angle);
+ setPlanCamDirection(coords.direction);
+ })
+ .start();
+ }
+ }, [selectedStarHR, planetCamera, posRef, scene]);
useFrame(() => {
if (tweenRef.current) {
diff --git a/src/components/PlanetCamera/Ground.jsx b/src/components/PlanetCamera/Ground.jsx
index 81ce663..2604cf5 100644
--- a/src/components/PlanetCamera/Ground.jsx
+++ b/src/components/PlanetCamera/Ground.jsx
@@ -1,65 +1,42 @@
import * as THREE from "three";
import { usePlanetCameraStore } from "./planetCameraStore";
+import { useSettingsStore } from "../../store";
export function Ground() {
- const groundSize = usePlanetCameraStore((s) => s.groundSize) / 10000;
const planetCameraTarget = usePlanetCameraStore((s) => s.planetCameraTarget);
+ const targetData = useSettingsStore((s) => s.getSetting(planetCameraTarget));
- // Define colors for each planet
- const planetGroundColors = {
- Earth: {
- ground: "#003300", // Dark green
- horizon: "#004400", // Slightly lighter green (very subtle)
- },
- Moon: {
- ground: "#4A4A4A", // Medium gray
- horizon: "#8B8B8B", // Lighter gray
- },
- Mars: {
- ground: "#B7410E", // Rust orange-red
- horizon: "#D2691E", // Chocolate/tan
- },
- Mercury: {
- ground: "#696969", // Dim gray (darker than Moon)
- horizon: "#A9A9A9", // Dark gray (lighter than ground)
- },
- Venus: {
- ground: "#B8860B", // Dark goldenrod
- horizon: "#DAA520", // Lighter goldenrod
- },
- Sun: {
- ground: "#FFA500", // Orange
- horizon: "#FFD700", // Gold
- },
- };
+ const groundColor = targetData?.groundColor || "#000080";
+ const horizonColor = targetData?.horizonColor || "#0000a0";
- // Get colors for current planet, default to Earth if not found
- const colors =
- planetGroundColors[planetCameraTarget] || planetGroundColors.Earth;
+ const groundSize = 0.015;
return (
- {/* Horizon ring (torus) - slightly brighter/different color */}
-
-
+ {/* Horizon ring - Base opacity 0.1 */}
+
+
- {/* Ground hemisphere */}
-
+ {/* ADDED: isBowl: true */}
+
diff --git a/src/components/PlanetCamera/PlanetCamera.jsx b/src/components/PlanetCamera/PlanetCamera.jsx
index 6f4aa64..399fc03 100644
--- a/src/components/PlanetCamera/PlanetCamera.jsx
+++ b/src/components/PlanetCamera/PlanetCamera.jsx
@@ -1,10 +1,8 @@
import { useEffect, useLayoutEffect, useRef } from "react";
-import { CameraHelper, Vector3 } from "three";
import * as THREE from "three";
-import { useThree } from "@react-three/fiber";
-import { PerspectiveCamera, useHelper } from "@react-three/drei";
-import { useStore } from "../../store";
-import { useSettingsStore } from "../../store";
+import { useFrame, useThree } from "@react-three/fiber";
+import { PerspectiveCamera } from "@react-three/drei";
+import { useStore, useSettingsStore } from "../../store";
import { usePlanetCameraStore } from "./planetCameraStore";
import {
latToRad,
@@ -25,163 +23,177 @@ export default function PlanetCamera() {
const camMountRef = useRef(null);
const groundMountRef = useRef(null);
const targetObjRef = useRef(null);
- const prevTargetRef = useRef(null); // ADD THIS LINE
+ const groundFade = useRef(0);
+
+ const CAM_NEAR_UNITS = 0.00007;
const { scene } = useThree();
const planetCamera = useStore((s) => s.planetCamera);
- const planetCameraHelper = useStore((s) => s.planetCameraHelper);
-
const cameraTransitioning = useStore((s) => s.cameraTransitioning);
-
const planetCameraTarget = usePlanetCameraStore((s) => s.planetCameraTarget);
- const planCamLat = usePlanetCameraStore((s) => s.planCamLat);
- const planCamLong = usePlanetCameraStore((s) => s.planCamLong);
- const planCamHeight = usePlanetCameraStore((s) => s.planCamHeight);
- const planCamAngle = usePlanetCameraStore((s) => s.planCamAngle);
- const planCamDirection = usePlanetCameraStore((s) => s.planCamDirection);
- const planCamFov = usePlanetCameraStore((s) => s.planCamFov);
- const planCamFar = usePlanetCameraStore((s) => s.planCamFar);
+ const {
+ planCamLat,
+ planCamLong,
+ planCamHeight,
+ planCamAngle,
+ planCamDirection,
+ planCamFov,
+ planCamFar,
+ showGround,
+ } = usePlanetCameraStore();
- const groundHeight = kmToUnits(usePlanetCameraStore((s) => s.groundHeight));
- const showGround = usePlanetCameraStore((s) => s.showGround);
+ const planetRadiusKm = unitsToKm(
+ useSettingsStore.getState().getSetting(planetCameraTarget)?.actualSize ||
+ 0.00426
+ );
- const getSetting = useSettingsStore((s) => s.getSetting);
- const planetSettings = getSetting(planetCameraTarget);
- const planetRadiusInUnits = planetSettings?.actualSize || 0.00426;
- const planetRadiusKm = unitsToKm(planetRadiusInUnits);
+ const setStarScale = useStore((s) => s.setStarScale);
+ const originalScaleRef = useRef(null);
- useLayoutEffect(() => {
- // Reset previous target's opacity if it exists
- if (prevTargetRef.current && prevTargetRef.current.material) {
- prevTargetRef.current.material.opacity = 1;
- prevTargetRef.current.material.needsUpdate = true;
+ // 1. CAPTURE & RESTORE EFFECT
+ useEffect(() => {
+ if (planetCamera) {
+ originalScaleRef.current = useStore.getState().starScale;
}
- // Remove camera system from previous parent
- if (planetCamSystemRef.current.parent) {
- planetCamSystemRef.current.parent.remove(planetCamSystemRef.current);
+ return () => {
+ if (planetCamera && originalScaleRef.current !== null) {
+ setStarScale(originalScaleRef.current);
+ originalScaleRef.current = null;
+ }
+ };
+ }, [planetCamera, setStarScale]);
+
+ // 2. DYNAMIC ZOOM EFFECT
+ useEffect(() => {
+ if (planetCamera && originalScaleRef.current !== null) {
+ const rawRatio = 45 / planCamFov;
+ const dampenedRatio = Math.pow(rawRatio, 0.5);
+ setStarScale(originalScaleRef.current * dampenedRatio);
}
+ }, [planCamFov, planetCamera, setStarScale]);
- // Add to new planet
+ useLayoutEffect(() => {
+ if (planetCamSystemRef.current.parent)
+ planetCamSystemRef.current.parent.remove(planetCamSystemRef.current);
targetObjRef.current = scene.getObjectByName(planetCameraTarget);
- if (targetObjRef.current) {
+ if (targetObjRef.current)
targetObjRef.current.add(planetCamSystemRef.current);
- planetCamRef.current.updateProjectionMatrix();
- }
-
- // Store current target for next switch
- prevTargetRef.current = targetObjRef.current;
}, [planetCameraTarget, scene]);
- useHelper(
- planetCameraHelper && !planetCamera ? planetCamRef : false,
- CameraHelper
- );
-
useEffect(() => {
- if (groundMountRef.current) {
- groundMountRef.current.traverse((child) => {
- if (child.isMesh && child.geometry) {
- if (child.geometry.type === "SphereGeometry") {
- // Only control the sphere visibility, leave torus alone
- child.visible = showGround;
- }
- // TorusGeometry is left unchanged - always visible
- }
- });
+ // Reset the atmospheric fade-in whenever a transition starts,
+ // OR whenever the target planet changes abruptly.
+ groundFade.current = 0;
+ }, [cameraTransitioning, planetCameraTarget]);
+
+ // 3. OPTIMIZED CROSSFADE (Decoupled Fades & Horizon Toggle)
+ useFrame((_, delta) => {
+ if (cameraTransitioning) return;
+
+ if (!planetCamera && targetObjRef.current?.material) {
+ targetObjRef.current.material.opacity = 1;
+ if (groundMountRef.current) groundMountRef.current.visible = false;
+ return;
}
- }, [showGround]);
-
- useEffect(() => {
- if (!latAxisRef.current || cameraTransitioning) return; // Skip opacity changes during transition
-
- if (targetObjRef.current && targetObjRef.current.material) {
- // Dynamic transition based on planet radius - relative scaling
- // const lowHeight = planetRadiusKm * 1.03; // Start fade at 3% above surface
- // const highHeight = planetRadiusKm * 1.04; // End fade at 4% above surface
- // Reduce to 0.5% start and 1.0% end
- const lowHeight = planetRadiusKm * 1.0005;
- const highHeight = planetRadiusKm * 1.001;
-
- let planetOpacity, groundOpacity;
-
- if (planCamHeight <= lowHeight) {
- planetOpacity = 0;
- groundOpacity = 1;
- } else if (planCamHeight >= highHeight) {
- planetOpacity = 1;
- groundOpacity = 0;
- } else {
- const fadeProgress =
- (planCamHeight - lowHeight) / (highHeight - lowHeight);
- const aggressiveFade = Math.pow(fadeProgress, 3);
- planetOpacity = aggressiveFade;
- groundOpacity = 1 - aggressiveFade;
- }
- // Apply opacity to planet
- targetObjRef.current.material.opacity = planetOpacity;
- targetObjRef.current.material.needsUpdate = true;
+ const nearClipKm = unitsToKm(CAM_NEAR_UNITS);
+
+ // PLANET FADE: Fades in early so the true curved horizon appears in front of the bowl
+ const pLow = planetRadiusKm * 1.0005; // ~3km (Starts fading in)
+ const pHigh = planetRadiusKm * 1.002; // ~12km (Fully opaque)
+
+ // GROUND FADE: Fades out much later to patch the clipping hole beneath the camera
+ const gLow = planetRadiusKm + nearClipKm * 0.8; // ~80km (Starts fading out)
+ const gHigh = planetRadiusKm + nearClipKm * 2.0; // ~200km (Fully gone)
+
+ // Calculate independent opacities
+ let pOpacity =
+ planCamHeight <= pLow
+ ? 0
+ : planCamHeight >= pHigh
+ ? 1
+ : Math.pow((planCamHeight - pLow) / (pHigh - pLow), 2); // Squared for smooth entry
+
+ let gOpacity =
+ planCamHeight <= gLow
+ ? 1
+ : planCamHeight >= gHigh
+ ? 0
+ : 1 - Math.pow((planCamHeight - gLow) / (gHigh - gLow), 2);
+
+ // Apply Planet Visibility
+ if (targetObjRef.current?.material) {
+ targetObjRef.current.material.transparent = true;
+ // CORRECT: Planet is completely hidden (0) when showGround is false
+ targetObjRef.current.material.opacity = showGround ? pOpacity : 0;
+ }
- // Apply opacity to ground
- if (groundMountRef.current) {
- groundMountRef.current.traverse((child) => {
- if (child.isMesh && child.material) {
- child.material.opacity = groundOpacity;
- child.material.needsUpdate = true;
- }
- });
-
- // Control visibility for performance
- groundMountRef.current.visible = groundOpacity > 0;
+ // Apply Ground & Horizon Line Visibility
+ if (planetCamera) {
+ if (groundFade.current < 1) {
+ groundFade.current = Math.min(1, groundFade.current + delta * 0.4);
}
- if (!planetCamera) {
- // Get all settings for celestial objects
- const { settings } = useSettingsStore.getState();
-
- // Reset only planets that have planetCamera enabled
- settings.forEach((setting) => {
- if (setting.planetCamera === true) {
- const planetObj = scene.getObjectByName(setting.name);
- if (planetObj && planetObj.material) {
- planetObj.material.opacity = 1;
- planetObj.material.needsUpdate = true;
- }
- }
- });
- // Hide ground
- if (groundMountRef.current) {
+ if (groundMountRef.current) {
+ if (gOpacity > 0) {
groundMountRef.current.traverse((child) => {
if (child.isMesh && child.material) {
- child.material.opacity = 0;
- child.material.needsUpdate = true;
+ const baseOpacity = child.userData.baseOpacity || 1;
+ const isBowl = child.userData.isBowl;
+
+ // LOGIC: If showGround is unchecked, force the bowl to 0 opacity,
+ // but let the horizon line render normally based on altitude!
+ let finalOpacity = 0;
+ if (!showGround && isBowl) {
+ finalOpacity = 0;
+ } else {
+ finalOpacity = baseOpacity * groundFade.current * gOpacity;
+ }
+
+ child.material.transparent = true;
+ child.material.opacity = finalOpacity;
+ // Minor optimization: hide the mesh entirely if it is fully transparent
+ child.visible = finalOpacity > 0;
}
});
+ groundMountRef.current.visible = true;
+ } else {
+ groundMountRef.current.visible = false;
}
}
-
- if (!showGround) {
- // Hide planet
- targetObjRef.current.material.opacity = 0;
- targetObjRef.current.material.needsUpdate = true;
- }
+ } else if (groundMountRef.current) {
+ groundMountRef.current.visible = false;
}
+ });
- planetCamRef.current.updateProjectionMatrix();
- }, [planCamHeight, planetCamera, planetRadiusKm, showGround]);
-
+ // 4. COORDINATES & DIP TRACKING
useEffect(() => {
if (!latAxisRef.current) return;
latAxisRef.current.rotation.x = latToRad(planCamLat);
longAxisRef.current.rotation.y = longToRad(planCamLong);
- camMountRef.current.position.y = kmToUnits(planCamHeight);
+
+ const camY = kmToUnits(planCamHeight);
+ camMountRef.current.position.y = camY;
+
+ if (groundMountRef.current) {
+ const H = planCamHeight;
+ const R = planetRadiusKm;
+
+ const dipAngleRad = H > R ? Math.acos(R / H) : 0;
+ const groundSizeUnits = 0.015;
+
+ // FIX: Use Math.tan to perfectly sync the visual angle for smaller planets
+ const yDropUnits = groundSizeUnits * Math.tan(dipAngleRad);
+
+ groundMountRef.current.position.y = camY - yDropUnits;
+ }
+
planetCamRef.current.rotation.x = altToRad(planCamAngle);
planetCamRef.current.rotation.y = dirToRad(planCamDirection);
planetCamRef.current.fov = planCamFov;
planetCamRef.current.far = lyToUnits(planCamFar);
-
planetCamRef.current.updateProjectionMatrix();
}, [
planCamLat,
@@ -191,42 +203,31 @@ export default function PlanetCamera() {
planCamDirection,
planCamFov,
planCamFar,
- latAxisRef,
+ planetRadiusKm,
]);
- useEffect(() => {
- // Reset camera to surface when switching planets
- const setPlanCamHeight = usePlanetCameraStore.getState().setPlanCamHeight;
- setPlanCamHeight(planetRadiusKm);
- }, [planetCameraTarget, planetRadiusKm]);
-
return (
- <>
-
-
-
-
-
-
-
- {/* Camera */}
-
-
-
-
+
+
+
+
+
+
+
+
- >
+
);
}
diff --git a/src/components/PlanetCamera/PlanetCameraHelper.jsx b/src/components/PlanetCamera/PlanetCameraHelper.jsx
new file mode 100644
index 0000000..c3e0c22
--- /dev/null
+++ b/src/components/PlanetCamera/PlanetCameraHelper.jsx
@@ -0,0 +1,124 @@
+import { useRef, useMemo } from "react";
+import { useFrame, useThree } from "@react-three/fiber";
+import * as THREE from "three";
+import { Line2 } from "three/examples/jsm/lines/Line2";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial";
+import { useStore } from "../../store";
+import { usePlanetCameraStore } from "./planetCameraStore";
+
+// Match your project's helper sizes
+const MARKER_SIZE = 0.0002;
+const shaftLength = MARKER_SIZE;
+const shaftRadius = MARKER_SIZE * 0.05;
+const headLength = MARKER_SIZE * 0.2;
+const headRadius = MARKER_SIZE * 0.15;
+
+const RUNWAY_LENGTH_MULT = 100000000;
+const RUNWAY_HEIGHT_MULT = 0.2;
+const RUNWAY_MARKER_SIZE = 0.02;
+
+// Geometries defined outside to prevent memory leaks (as per your standards)
+const shaftGeo = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 8);
+const headGeo = new THREE.ConeGeometry(headRadius, headLength, 8);
+const runwaySphereGeo = new THREE.SphereGeometry(1, 16, 16);
+
+const whiteMat = new THREE.MeshBasicMaterial({ color: "white", depthTest: false, depthWrite: false, transparent: true });
+const redMat = new THREE.MeshBasicMaterial({ color: "red", depthTest: false, depthWrite: false, transparent: true });
+const cyanMat = new THREE.MeshBasicMaterial({ color: "cyan", depthTest: false, depthWrite: false, transparent: true, opacity: 0.8 });
+
+export default function PlanetCameraHelper() {
+ const planetCameraHelper = useStore((s) => s.planetCameraHelper);
+ const target = usePlanetCameraStore((s) => s.planetCameraTarget);
+ const { scene, size } = useThree();
+
+ const groupRef = useRef(null);
+ const runwayMarkerRef = useRef(null);
+ const lineRef = useRef(null);
+
+ // Initialize LineGeometry and Material exactly like your TraceLine.jsx
+ const [lineGeom, lineMat] = useMemo(() => {
+ const geometry = new LineGeometry();
+ // Initialize with 2 points [x,y,z, x,y,z]
+ geometry.setPositions([0, 0, 0, 0, 0, 0]);
+
+ const material = new LineMaterial({
+ color: 0x00ffff,
+ linewidth: 2,
+ transparent: true,
+ opacity: 0.8,
+ depthTest: false,
+ depthWrite: false,
+ resolution: new THREE.Vector2(size.width, size.height),
+ });
+ return [geometry, material];
+ }, []);
+
+ useFrame(() => {
+ if (!planetCameraHelper) return;
+
+ const cam = scene.getObjectByName("PlanetCamera");
+ const pObj = scene.getObjectByName(target);
+
+ if (cam && cam.parent && pObj && groupRef.current) {
+ // 1. Position Arrow Helpers
+ const pos = new THREE.Vector3();
+ cam.getWorldPosition(pos);
+ groupRef.current.position.copy(pos);
+ cam.getWorldQuaternion(groupRef.current.quaternion);
+
+ // 2. Calculate Runway
+ const yaw = cam.rotation.y;
+ const localBackward = new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw)).normalize();
+ const worldBackward = localBackward.transformDirection(cam.parent.matrixWorld).normalize();
+ const worldUp = new THREE.Vector3(0, 1, 0).transformDirection(cam.parent.matrixWorld).normalize();
+
+ const planetPos = new THREE.Vector3();
+ pObj.getWorldPosition(planetPos);
+ const altitude = pos.distanceTo(planetPos);
+
+ const runwayPos = pos.clone()
+ .add(worldBackward.multiplyScalar(altitude * RUNWAY_LENGTH_MULT))
+ .add(worldUp.multiplyScalar(altitude * RUNWAY_HEIGHT_MULT));
+
+ // 3. Update Line Buffer
+ lineGeom.setPositions([
+ pos.x, pos.y, pos.z,
+ runwayPos.x, runwayPos.y, runwayPos.z
+ ]);
+
+ // Update resolution uniform (critical for LineMaterial visibility)
+ lineMat.resolution.set(size.width, size.height);
+
+ // 4. Update Marker
+ if (runwayMarkerRef.current) {
+ runwayMarkerRef.current.position.copy(runwayPos);
+ runwayMarkerRef.current.scale.setScalar(pos.distanceTo(runwayPos) * RUNWAY_MARKER_SIZE);
+ }
+ }
+ });
+
+ if (!planetCameraHelper) return null;
+
+ return (
+ <>
+
+ {/* Forward Arrow */}
+
+
+
+
+ {/* Up Arrow */}
+
+
+
+
+
+
+ {/* The Line - Using the same imperative setup as your orbits */}
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/PlanetCamera/PlanetCameraUI.jsx b/src/components/PlanetCamera/PlanetCameraUI.jsx
index 557d2ea..dc78c55 100644
--- a/src/components/PlanetCamera/PlanetCameraUI.jsx
+++ b/src/components/PlanetCamera/PlanetCameraUI.jsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom"; // <--- Import createPortal
import { useControls, Leva, useCreateStore } from "leva";
import { useGesture } from "@use-gesture/react";
import { useStore, useSettingsStore } from "../../store";
@@ -51,19 +52,19 @@ const PlanetCameraUI = () => {
const locationsByPlanet = {
Earth: {
"-": { lat: null, long: null },
-
+
// --- Europe ---
"Athens, Greece": { lat: 37.98, long: 23.73 },
"Berlin, Germany": { lat: 52.52, long: 13.41 },
"Istanbul, Turkey": { lat: 41.01, long: 28.98 },
"London, UK": { lat: 51.51, long: -0.13 },
- "Madrid, Spain": { lat: 40.41, long: -3.70 },
+ "Madrid, Spain": { lat: 40.41, long: -3.7 },
"Moscow, Russia": { lat: 55.76, long: 37.62 },
"Paris, France": { lat: 48.86, long: 2.35 },
"Reykjavik, Iceland": { lat: 64.15, long: -21.94 },
"Rome, Italy": { lat: 41.9, long: 12.5 },
"Stockholm, Sweden": { lat: 59.33, long: 18.07 },
-
+
// --- North America ---
"Anchorage, USA": { lat: 61.22, long: -149.9 },
"Los Angeles, USA": { lat: 34.05, long: -118.24 },
@@ -72,7 +73,7 @@ const PlanetCameraUI = () => {
"Clear Creek Abbey, USA": { lat: 36.03, long: -95.19 },
"Toronto, Canada": { lat: 43.65, long: -79.38 },
"Vancouver, Canada": { lat: 49.28, long: -123.12 },
-
+
// --- South America ---
"Bogota, Colombia": { lat: 4.71, long: -74.07 },
"Buenos Aires, Arg.": { lat: -34.6, long: -58.38 },
@@ -80,7 +81,7 @@ const PlanetCameraUI = () => {
"Rio de Jan., Brazil": { lat: -22.91, long: -43.17 },
"Santiago, Chile": { lat: -33.45, long: -70.67 },
"Sao Paulo, Brazil": { lat: -23.55, long: -46.63 },
-
+
// --- Asia ---
"Bangkok, Thailand": { lat: 13.76, long: 100.5 },
"Beijing, China": { lat: 39.9, long: 116.41 },
@@ -91,21 +92,21 @@ const PlanetCameraUI = () => {
"Shanghai, China": { lat: 31.23, long: 121.47 },
"Singapore, Singap.": { lat: 1.35, long: 103.82 },
"Tokyo, Japan": { lat: 35.68, long: 139.69 },
-
+
// --- Africa ---
"Cairo, Egypt": { lat: 30.04, long: 31.24 },
"Cape Town, S.A.": { lat: -33.92, long: 18.42 },
"Lagos, Nigeria": { lat: 6.52, long: 3.38 },
"Nairobi, Kenya": { lat: -1.29, long: 36.82 },
-
+
// --- Australia / Oceania ---
"Melbourne, Aus.": { lat: -37.81, long: 144.96 },
"Sydney, Aus.": { lat: -33.87, long: 151.21 },
"Wellington, N.Z.": { lat: -41.29, long: 174.78 },
-
+
// --- Poles ---
- "North Pole": { lat: 90.00, long: 0.00 },
- "South Pole": { lat: -90.00, long: 0.00 },
+ "North Pole": { lat: 90.0, long: 0.0 },
+ "South Pole": { lat: -90.0, long: 0.0 },
},
Moon: {
@@ -288,7 +289,8 @@ const PlanetCameraUI = () => {
}
);
- return (
+ // --- FIX: Portal to document.body to escape the #root scaling context ---
+ return createPortal(
<>
{planetCamera && (
@@ -302,7 +304,7 @@ const PlanetCameraUI = () => {
numberInputMinWidth: "60px",
},
fontSizes: {
- root: "16px",
+ root: "12px",
},
fonts: {
mono: "",
@@ -315,7 +317,8 @@ const PlanetCameraUI = () => {
/>
)}
- >
+ >,
+ document.body
);
};
diff --git a/src/components/PlanetCamera/TransitionCamera.jsx b/src/components/PlanetCamera/TransitionCamera.jsx
index 30527a8..addf9b3 100644
--- a/src/components/PlanetCamera/TransitionCamera.jsx
+++ b/src/components/PlanetCamera/TransitionCamera.jsx
@@ -1,227 +1,335 @@
-import { useRef, useEffect } from "react";
+import { useRef, useEffect, useLayoutEffect, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { PerspectiveCamera } from "@react-three/drei";
-import { Vector3, Quaternion, CubicBezierCurve3 } from "three";
-import { useStore } from "../../store";
-import { usePlanetCameraStore } from "./planetCameraStore";
import * as THREE from "three";
+import { useStore, useSettingsStore } from "../../store";
+import { usePlanetCameraStore } from "./planetCameraStore";
+import { Ground } from "./Ground";
+import { kmToUnits, unitsToKm } from "../../utils/celestial-functions";
-export default function TransitionCamera() {
- const transitionCamRef = useRef(null);
- const curveRef = useRef(null);
- const lookAtTargetRef = useRef(new Vector3());
- const progress = useRef(0);
+const DUR = 12.0;
+const ORBIT_PCT = 0.4;
+const GLIDE_PCT = 0.85;
+const RUNWAY = 0.5;
+export default function TransitionCamera() {
const { scene } = useThree();
+ const cam = useRef(null);
+ const groundRef = useRef(null);
const planetCamera = useStore((s) => s.planetCamera);
- const cameraTransitioning = useStore((s) => s.cameraTransitioning);
- const setCameraTransitioning = useStore((s) => s.setCameraTransitioning);
-
- const planetCameraTarget = usePlanetCameraStore((s) => s.planetCameraTarget);
-
- const startQuat = useRef(new Quaternion());
- const endQuat = useRef(new Quaternion());
- const startFov = useRef(15);
+ const { cameraTransitioning, setCameraTransitioning } = useStore();
+
+ // --- NEW: Pull in altitude and ground settings ---
+ const target = usePlanetCameraStore((s) => s.planetCameraTarget);
+ const planCamHeight = usePlanetCameraStore((s) => s.planCamHeight);
+ const showGround = usePlanetCameraStore((s) => s.showGround);
+
+ // --- NEW: Pre-calculate the exact final opacities based on altitude ---
+ const targetOpacities = useMemo(() => {
+ const pRadiusKm = unitsToKm(
+ useSettingsStore.getState().getSetting(target)?.actualSize || 0.00426
+ );
+ const low = pRadiusKm * 1.0005;
+ const high = pRadiusKm * 1.001;
+
+ let pOpacity =
+ planCamHeight <= low
+ ? 0
+ : planCamHeight >= high
+ ? 1
+ : Math.pow((planCamHeight - low) / (high - low), 3);
+
+ let gOpacity = 1 - pOpacity;
+
+ return {
+ planet: showGround ? pOpacity : 0,
+ ground: showGround ? gOpacity : 0,
+ };
+ }, [planCamHeight, target, showGround]);
+
+ const state = useMemo(
+ () => ({
+ t: 0,
+ orbitDist: 0,
+ startFov: 50,
+ endFov: 50,
+ startPos: new THREE.Vector3(),
+ startQuat: new THREE.Quaternion(),
+ endPos: new THREE.Vector3(),
+ endQuat: new THREE.Quaternion(),
+ center: new THREE.Vector3(),
+ curve: new THREE.CubicBezierCurve3(),
+ vStart: new THREE.Vector3(),
+ vMid: new THREE.Vector3(),
+ axis: new THREE.Vector3(),
+ dir: new THREE.Vector3(),
+ startUp: new THREE.Vector3(),
+ endUp: new THREE.Vector3(),
+ leveledUp: new THREE.Vector3(),
+ leveledQuat: new THREE.Quaternion(),
+ look: new THREE.Vector3(),
+ sight: new THREE.Vector3(),
+ startSight: new THREE.Vector3(),
+ vCur: new THREE.Vector3(),
+ temp: new THREE.Vector3(),
+ angle: 0,
+ groundPos: new THREE.Vector3(),
+ }),
+ []
+ );
useEffect(() => {
- if (planetCamera) {
- setCameraTransitioning(true);
- }
- }, [planetCamera]);
+ const skip = (e) => {
+ if (e.button === 0 && cameraTransitioning) state.t = 1.0;
+ };
+ window.addEventListener("pointerdown", skip);
+ return () => window.removeEventListener("pointerdown", skip);
+ }, [cameraTransitioning, state]);
useEffect(() => {
- const handleClick = (event) => {
- if (event.button === 0 && cameraTransitioning) {
- // Left click - stop transition immediately
- setCameraTransitioning(false);
- }
- };
+ if (planetCamera) setCameraTransitioning(true);
+ }, [planetCamera, setCameraTransitioning]);
+
+ useLayoutEffect(() => {
+ if (!cameraTransitioning || !cam.current) return;
+
+ const oCam = scene.getObjectByName("OrbitCamera");
+ const pCam = scene.getObjectByName("PlanetCamera");
+ const pObj = scene.getObjectByName(target);
+ const mount = pCam?.parent;
+ if (!oCam || !pCam || !pObj || !mount) return;
+
+ if (pObj.material) {
+ pObj.material.transparent = true;
+ pObj.material.opacity = 1.0;
+ pObj.material.needsUpdate = true;
+ }
- document.addEventListener("mousedown", handleClick);
- return () => {
- document.removeEventListener("mousedown", handleClick);
- };
- }, [cameraTransitioning]);
+ [oCam, pCam, pObj, mount].forEach((obj) => obj.updateMatrixWorld(true));
+
+ state.t = 0;
+ oCam.getWorldPosition(state.startPos);
+ oCam.getWorldQuaternion(state.startQuat);
+ pCam.getWorldPosition(state.endPos);
+ pCam.getWorldQuaternion(state.endQuat);
+ pObj.getWorldPosition(state.center);
+
+ state.startUp.set(0, 1, 0).applyQuaternion(state.startQuat);
+ state.endUp.set(0, 1, 0).applyQuaternion(state.endQuat);
+ pCam.getWorldDirection(state.dir);
+
+ state.startFov = oCam.fov;
+ state.endFov = pCam.fov;
+ state.orbitDist = state.startPos.distanceTo(state.center);
+
+ const oCamForward = new THREE.Vector3(0, 0, -1)
+ .applyQuaternion(state.startQuat)
+ .normalize();
+ state.startSight
+ .copy(state.startPos)
+ .add(oCamForward.multiplyScalar(state.orbitDist));
+
+ const mountQuat = new THREE.Quaternion();
+ mount.getWorldQuaternion(mountQuat);
+ const localYawQuat = new THREE.Quaternion().setFromAxisAngle(
+ new THREE.Vector3(0, 1, 0),
+ pCam.rotation.y
+ );
+ state.leveledQuat.copy(mountQuat).multiply(localYawQuat);
+
+ const worldBackward = new THREE.Vector3(0, 0, 1)
+ .applyQuaternion(state.leveledQuat)
+ .normalize();
+ state.leveledUp.set(0, 1, 0).applyQuaternion(state.leveledQuat).normalize();
+
+ const planetRadiusKm = unitsToKm(
+ useSettingsStore.getState().getSetting(target)?.actualSize || 0.00426
+ );
+ const groundHeightUnits = kmToUnits(
+ usePlanetCameraStore.getState().groundHeight
+ );
+ const groundOffset = kmToUnits(planetRadiusKm) + groundHeightUnits;
+ state.groundPos
+ .copy(state.center)
+ .add(state.leveledUp.clone().multiplyScalar(groundOffset));
+
+ const mid = state.center
+ .clone()
+ .add(
+ worldBackward
+ .clone()
+ .add(state.leveledUp)
+ .normalize()
+ .multiplyScalar(state.orbitDist)
+ );
- useEffect(() => {
- if (cameraTransitioning && transitionCamRef.current) {
- const orbitCam = scene.getObjectByName("OrbitCamera");
- const planetCam = scene.getObjectByName("PlanetCamera");
- const planetObj = scene.getObjectByName(planetCameraTarget);
-
- if (!orbitCam || !planetCam || !planetObj) return;
-
- // FORCE planet visible
- if (planetObj.material) {
- planetObj.material.transparent = true;
- planetObj.material.opacity = 1;
- planetObj.material.needsUpdate = true;
- }
+ const p2 = state.endPos
+ .clone()
+ .add(worldBackward.clone().multiplyScalar(state.orbitDist * RUNWAY));
+ const p1 = mid
+ .clone()
+ .lerp(p2, 0.5)
+ .add(state.leveledUp.clone().multiplyScalar(state.orbitDist * 0.4));
+ state.curve.v0.copy(mid);
+ state.curve.v1.copy(p1);
+ state.curve.v2.copy(p2);
+ state.curve.v3.copy(state.endPos);
+
+ const forward = new THREE.Vector3(0, 0, -1)
+ .applyQuaternion(state.leveledQuat)
+ .normalize();
+ state.sight
+ .copy(state.endPos)
+ .add(forward.multiplyScalar(state.orbitDist * 0.5));
+
+ state.vStart.subVectors(state.startPos, state.center).normalize();
+ state.vMid.subVectors(mid, state.center).normalize();
+ state.angle = state.vStart.angleTo(state.vMid);
+ state.axis.crossVectors(state.vStart, state.vMid).normalize();
+
+ cam.current.fov = state.startFov;
+ cam.current.position.copy(state.startPos);
+ cam.current.quaternion.copy(state.startQuat);
+ cam.current.updateProjectionMatrix();
+ }, [cameraTransitioning, scene, target, state]);
+
+ useFrame((_, delta) => {
+ if (!cameraTransitioning || !cam.current) return;
+ state.t += delta / DUR;
+
+ const pCam = scene.getObjectByName("PlanetCamera");
+ const pObj = scene.getObjectByName(target);
+
+ if (pCam) {
+ pCam.getWorldPosition(state.endPos);
+ pCam.getWorldQuaternion(state.endQuat);
+ }
- // Get positions
- const startPos = new Vector3();
- orbitCam.getWorldPosition(startPos);
-
- const endPos = new Vector3();
- planetCam.getWorldPosition(endPos);
-
- const planetCenter = new Vector3();
- planetObj.getWorldPosition(planetCenter);
-
- // Save orientations
- orbitCam.getWorldQuaternion(startQuat.current);
- planetCam.getWorldQuaternion(endQuat.current);
- startFov.current = orbitCam.fov;
-
- // DEBUG: Show planet camera actual orientation
- // const debugGroup1 = new THREE.Group();
- // debugGroup1.name = "debugPlanetCam";
- // debugGroup1.position.copy(endPos);
- // debugGroup1.quaternion.copy(endQuat.current);
-
- // const forwardArrow1 = new THREE.ArrowHelper(
- // new THREE.Vector3(0, 0, -1),
- // new THREE.Vector3(0, 0, 0),
- // 10,
- // 0x0000ff // Blue = forward
- // );
- // const upArrow1 = new THREE.ArrowHelper(
- // new THREE.Vector3(0, 1, 0),
- // new THREE.Vector3(0, 0, 0),
- // 10,
- // 0x00ff00 // Green = up
- // );
- // debugGroup1.add(forwardArrow1);
- // debugGroup1.add(upArrow1);
- // scene.add(debugGroup1);
-
- // Calculate approach (your existing code)
- const planetCamWorldDirection = new Vector3();
- planetCam.getWorldDirection(planetCamWorldDirection);
-
- const approachDirection = planetCamWorldDirection.clone().negate();
- const endDist = new Vector3().subVectors(endPos, planetCenter).length();
- const approachAltitude = endDist * 2;
- const approachDistance = endDist * 4;
-
- const approachPoint = endPos
- .clone()
- .add(approachDirection.multiplyScalar(approachDistance));
- const heightOffset = approachAltitude - endDist;
- const planetUp = new Vector3()
- .subVectors(endPos, planetCenter)
- .normalize();
- approachPoint.add(planetUp.multiplyScalar(heightOffset));
-
- const midPoint = new Vector3().lerpVectors(startPos, approachPoint, 0.5);
-
- // DEBUG: Show calculated end position and approach direction
- // const debugGroup2 = new THREE.Group();
- // debugGroup2.name = "debugCalculated";
- // debugGroup2.position.copy(endPos);
-
- // const approachArrow = new THREE.ArrowHelper(
- // approachDirection.normalize(),
- // new THREE.Vector3(0, 0, 0),
- // 10,
- // 0xff0000 // Red = approach direction
- // );
- // debugGroup2.add(approachArrow);
- // scene.add(debugGroup2);
-
- curveRef.current = new CubicBezierCurve3(
- startPos,
- midPoint,
- approachPoint,
- endPos
+ // --- FIX: DYNAMICALLY FADE PLANET OPACITY ---
+ if (pObj && pObj.material) {
+ // Linearly fade the planet from 1.0 to its target opacity as we approach
+ pObj.material.opacity = THREE.MathUtils.lerp(
+ 1.0,
+ targetOpacities.planet,
+ state.t
);
+ }
- // Set initial camera state
- transitionCamRef.current.position.copy(startPos);
- transitionCamRef.current.quaternion.copy(startQuat.current);
- transitionCamRef.current.fov = startFov.current;
- transitionCamRef.current.updateProjectionMatrix();
-
- lookAtTargetRef.current.copy(planetCenter);
- progress.current = 0;
-
- // Cleanup
- return () => {
- const debug1 = scene.getObjectByName("debugPlanetCam");
- const debug2 = scene.getObjectByName("debugCalculated");
- if (debug1) scene.remove(debug1);
- if (debug2) scene.remove(debug2);
- };
+ // --- FIX: CONDITIONAL GROUND FADE LOGIC ---
+ if (groundRef.current) {
+ groundRef.current.position.copy(state.groundPos);
+ groundRef.current.quaternion.copy(state.leveledQuat);
+
+ // Only fade in the ground if the target camera altitude says it should be visible
+ if (state.t > 0.5 && targetOpacities.ground > 0) {
+ groundRef.current.visible = true;
+ const fade = THREE.MathUtils.clamp((state.t - 0.5) / 0.3, 0, 1);
+
+ groundRef.current.traverse((child) => {
+ if (child.isMesh && child.material) {
+ const baseOpacity = child.userData.baseOpacity || 1;
+ child.material.transparent = true;
+ // Limit the maximum fade by the target's gOpacity
+ child.material.opacity =
+ baseOpacity * fade * targetOpacities.ground;
+ }
+ });
+ } else {
+ groundRef.current.visible = false;
+ }
}
- }, [cameraTransitioning]);
- const duration = 10; // seconds
+ if (state.t >= 1.0) {
+ cam.current.position.copy(state.endPos);
+ cam.current.quaternion.copy(state.endQuat);
+ cam.current.fov = state.endFov;
+ cam.current.updateProjectionMatrix();
- const blendStartQuat = useRef(new Quaternion());
+ // Ensure exact final opacity before handing off
+ if (pObj && pObj.material) pObj.material.opacity = targetOpacities.planet;
- useFrame((state, delta) => {
- if (cameraTransitioning && curveRef.current && transitionCamRef.current) {
- progress.current += delta / duration;
+ setCameraTransitioning(false);
+ return;
+ }
- //WIP Since the animation doesent work yet we end premature
- if (progress.current >= 0.5) {
- progress.current = 1;
- setCameraTransitioning(false);
- }
- //
- if (progress.current >= 1) {
- progress.current = 1;
- setCameraTransitioning(false);
+ const easeOutEx = (x) => 1 - Math.pow(1 - x, 6);
+ const easeInOut = (x) =>
+ x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
+
+ if (state.t <= ORBIT_PCT) {
+ const localT = state.t / ORBIT_PCT;
+ const t = easeInOut(localT);
+
+ state.vCur.copy(state.vStart).applyAxisAngle(state.axis, state.angle * t);
+ cam.current.position
+ .copy(state.center)
+ .add(state.vCur.multiplyScalar(state.orbitDist));
+
+ cam.current.up.copy(state.startUp);
+ state.look.copy(state.startSight).lerp(state.endPos, t);
+ cam.current.lookAt(state.look);
+ } else if (state.t <= GLIDE_PCT) {
+ const localT = (state.t - ORBIT_PCT) / (GLIDE_PCT - ORBIT_PCT);
+ const tPos = easeOutEx(localT);
+ const tLook = easeInOut(localT);
+
+ state.curve.getPoint(tPos, cam.current.position);
+
+ const landingRadius = state.endPos.distanceTo(state.center);
+ const currentRadius = cam.current.position.distanceTo(state.center);
+
+ if (currentRadius < landingRadius) {
+ const outwardDir = cam.current.position
+ .clone()
+ .sub(state.center)
+ .normalize();
+ cam.current.position
+ .copy(state.center)
+ .add(outwardDir.multiplyScalar(landingRadius));
}
- const eased = 1 - Math.pow(1 - progress.current, 8);
+ cam.current.up
+ .copy(state.startUp)
+ .lerp(state.leveledUp, tLook)
+ .normalize();
- // Get point on curve
- const point = curveRef.current.getPoint(eased);
- transitionCamRef.current.position.copy(point);
+ state.look.copy(state.endPos).lerp(state.sight, tLook);
+ cam.current.lookAt(state.look);
- if (progress.current < 0.95) {
- // First 95%: look at planet with gradually rotating UP
- const defaultUp = new Vector3(0, 1, 0);
- const planetCamUp = new Vector3(0, 1, 0).applyQuaternion(
- endQuat.current
- );
+ cam.current.fov = THREE.MathUtils.lerp(
+ state.startFov,
+ state.endFov,
+ tLook
+ );
+ cam.current.updateProjectionMatrix();
+ } else {
+ const localT = (state.t - GLIDE_PCT) / (1 - GLIDE_PCT);
+ const t = easeInOut(localT);
- const blendedUp = new Vector3().lerpVectors(
- defaultUp,
- planetCamUp,
- eased
- );
+ cam.current.position.copy(state.endPos);
- transitionCamRef.current.up.copy(blendedUp);
- transitionCamRef.current.lookAt(lookAtTargetRef.current);
+ cam.current.quaternion.slerpQuaternions(
+ state.leveledQuat,
+ state.endQuat,
+ t
+ );
- // Save quaternion at 95% for smooth blend
- if (progress.current >= 0.94) {
- blendStartQuat.current.copy(transitionCamRef.current.quaternion);
- }
- } else {
- // Last 5%: gentle blend to final orientation
- const blendProgress = (progress.current - 0.95) / 0.05;
- const currentQuat = new Quaternion();
-
- currentQuat.slerpQuaternions(
- blendStartQuat.current,
- endQuat.current,
- blendProgress
- );
- transitionCamRef.current.quaternion.copy(currentQuat);
- }
+ cam.current.fov = state.endFov;
+ cam.current.updateProjectionMatrix();
}
});
- if (!cameraTransitioning) return null;
return (
-
+ <>
+
+ {/*
+
+ */}
+ >
);
}
diff --git a/src/components/PlanetCamera/planetCameraStore.js b/src/components/PlanetCamera/planetCameraStore.js
index 103da00..75b8f9e 100644
--- a/src/components/PlanetCamera/planetCameraStore.js
+++ b/src/components/PlanetCamera/planetCameraStore.js
@@ -10,7 +10,7 @@ export const usePlanetCameraStore = create((set) => ({
planCamLong: 0,
setPlanCamLong: (v) => set({ planCamLong: v }),
- planCamHeight: 6370,
+ planCamHeight: 6370.002,
setPlanCamHeight: (v) => set({ planCamHeight: v }),
planCamAngle: 0,
diff --git a/src/components/Menus/Ephemerides.jsx b/src/components/Plot/Plot.jsx
similarity index 65%
rename from src/components/Menus/Ephemerides.jsx
rename to src/components/Plot/Plot.jsx
index c646bf2..3e9a11c 100644
--- a/src/components/Menus/Ephemerides.jsx
+++ b/src/components/Plot/Plot.jsx
@@ -1,21 +1,29 @@
import { useEffect, useRef } from "react";
import { useControls, useCreateStore, Leva, button } from "leva";
-import { useStore, useSettingsStore } from "../../store";
+import { useStore, useSettingsStore, usePlotStore } from "../../store";
import {
isValidDate,
posToDate,
speedFactOpts,
sDay,
} from "../../utils/time-date-functions";
-// Import the new store
-import { useEphemeridesStore } from "./ephemeridesStore";
+import { usePlotterStore } from "./plotStore";
-const Ephemerides = () => {
- const { ephimerides, posRef } = useStore();
+const Plot = () => {
+ const { plot, posRef } = useStore(); // Using existing posRef
const { settings } = useSettingsStore();
- const setGenerationParams = useEphemeridesStore((s) => s.setGenerationParams);
- const levaEphStore = useCreateStore();
+ // We reuse the 'ephimerides' flag in the main store to show this UI,
+ // or you might want to create a new 'showPlot' flag in your main store.
+ // Assuming for now we rely on a similar trigger or a new one.
+ // *Note: You likely need to add a 'plot' boolean to your main store.js if you want a separate menu toggle.*
+ // For this example, I will assume this component is rendered when active.
+
+ const setGenerationParams = usePlotStore((s) => s.setGenerationParams);
+ const isGenerating = usePlotStore((s) => s.isGenerating);
+ const clearResults = usePlotStore((s) => s.clearResults);
+
+ const levaPlotStore = useCreateStore();
const valuesRef = useRef({
"Start Date": posToDate(posRef.current),
@@ -26,7 +34,7 @@ const Ephemerides = () => {
const checkboxes = {};
settings.forEach((s) => {
- if (s.type === "planet" && s.name !== "Earth") {
+ if (s.type === "planet") {
if (valuesRef.current[s.name] === undefined) {
valuesRef.current[s.name] = false;
}
@@ -40,14 +48,15 @@ const Ephemerides = () => {
});
const handleCreate = () => {
+ if (isGenerating) return;
+
const formValues = valuesRef.current;
const checkedPlanets = settings
- .filter((s) => s.type === "planet" && s.name !== "Earth")
+ .filter((s) => s.type === "planet")
.filter((s) => formValues[s.name] === true)
.map((s) => s.name);
- // Send command to the Controller inside the Canvas
setGenerationParams({
startDate: formValues["Start Date"],
endDate: formValues["End Date"],
@@ -58,35 +67,32 @@ const Ephemerides = () => {
};
const handleInvalidInput = (field, fallbackValue) => {
- levaEphStore.set({ [field]: fallbackValue });
+ levaPlotStore.set({ [field]: fallbackValue });
valuesRef.current[field] = fallbackValue;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
- // Effect to update Start/End Date to current position when menu is opened
+ // Initialize dates
useEffect(() => {
- if (ephimerides) {
+ if (posRef.current) {
const currentDate = posToDate(posRef.current);
-
- // Update valuesRef so generation uses the new date
valuesRef.current["Start Date"] = currentDate;
valuesRef.current["End Date"] = currentDate;
-
- // Update Leva UI to show the new date
- levaEphStore.set({
- "Start Date": currentDate,
- "End Date": currentDate
+ levaPlotStore.set({
+ "Start Date": currentDate,
+ "End Date": currentDate,
});
}
- }, [ephimerides, posRef, levaEphStore]);
+ }, [posRef, levaPlotStore]);
useControls(
{
- Generate: button(handleCreate),
+ "Generate Plots": button(handleCreate, { disabled: isGenerating }),
+ "Clear Plots": button(clearResults),
"Start Date": {
- value: posToDate(posRef.current),
+ value: posToDate(posRef.current || 0),
onChange: (v) => {
valuesRef.current["Start Date"] = v;
},
@@ -96,7 +102,7 @@ const Ephemerides = () => {
},
},
"End Date": {
- value: posToDate(posRef.current),
+ value: posToDate(posRef.current || 0),
onChange: (v) => {
valuesRef.current["End Date"] = v;
},
@@ -124,17 +130,17 @@ const Ephemerides = () => {
},
...checkboxes,
},
- { store: levaEphStore },
- [settings]
+ { store: levaPlotStore },
+ [settings, isGenerating]
);
return (
<>
- {ephimerides && (
+ {plot && (
{
);
};
-export default Ephemerides;
\ No newline at end of file
+export default Plot;
diff --git a/src/components/Plot/PlotController.jsx b/src/components/Plot/PlotController.jsx
new file mode 100644
index 0000000..9c15631
--- /dev/null
+++ b/src/components/Plot/PlotController.jsx
@@ -0,0 +1,123 @@
+import { useEffect, useState, useRef } from "react";
+import { useThree, useFrame } from "@react-three/fiber";
+import { Vector3 } from "three";
+import { usePlotStore } from "../../store"; // The main store for plot objects
+import { usePlotterStore } from "./plotStore"; // The local store for this feature
+import { posToDate, dateTimeToPos } from "../../utils/time-date-functions";
+import { movePlotModel } from "../../utils/plotModelFunctions";
+import PlotVisuals from "./PlotVisuals";
+
+const PlotController = () => {
+ // Use 'scene' if we need to access world manually, but plotObjects have refs
+ const plotObjects = usePlotObjectsStore((s) => s.plotObjects);
+
+ const {
+ trigger,
+ params,
+ resetTrigger,
+ setGeneratedData,
+ setGenerationError,
+ setIsGenerating,
+ generatedData,
+ showResult,
+ } = usePlotStore();
+
+ const [generating, setGenerating] = useState(false);
+
+ const jobRef = useRef({
+ currentPos: 0,
+ endPos: 0,
+ increment: 0,
+ checkedPlanets: [],
+ data: {},
+ totalSteps: 0,
+ });
+
+ // 1. Initialize Job
+ useEffect(() => {
+ if (trigger && params) {
+ setIsGenerating(true);
+
+ const startPos = dateTimeToPos(params.startDate, "00:00:00");
+ const endPos = dateTimeToPos(params.endDate, "00:00:00");
+ const increment = params.stepSize * params.stepFactor;
+
+ const calculatedSteps = Math.floor((endPos - startPos) / increment) + 1;
+ const totalOperations = calculatedSteps * params.checkedPlanets.length;
+
+ // Safety Limit
+ if (totalOperations > 200000) {
+ const errorMsg = `Too many points to plot (${totalOperations}). Please reduce range or increase step size.`;
+ setGenerationError(errorMsg);
+ setIsGenerating(false);
+ resetTrigger();
+ return;
+ }
+
+ const initialData = {};
+ params.checkedPlanets.forEach((planet) => {
+ initialData[planet] = [];
+ });
+
+ jobRef.current = {
+ currentPos: startPos,
+ endPos: endPos,
+ increment: increment,
+ checkedPlanets: params.checkedPlanets,
+ data: initialData,
+ totalSteps: calculatedSteps,
+ };
+
+ setGenerating(true);
+ resetTrigger();
+ }
+ }, [trigger, params, resetTrigger, setGenerationError, setIsGenerating]);
+
+ // 2. Process Job
+ useFrame(() => {
+ if (!generating) return;
+
+ const job = jobRef.current;
+ const BATCH_SIZE = 100; // Higher batch size for simple position gathering
+ let batchCount = 0;
+
+ while (job.currentPos <= job.endPos && batchCount < BATCH_SIZE) {
+ // Move the invisible calculation model
+ movePlotModel(plotObjects, job.currentPos);
+
+ // Collect positions
+ job.checkedPlanets.forEach((name) => {
+ let targetName = name;
+ if (name === "Moon") {
+ const hasActualMoon = plotObjects.some(
+ (p) => p.name === "Actual Moon"
+ );
+ if (hasActualMoon) targetName = "Actual Moon";
+ }
+
+ const targetObj = plotObjects.find((p) => p.name === targetName);
+
+ if (targetObj && targetObj.pivotRef && targetObj.pivotRef.current) {
+ const vec = new Vector3();
+ targetObj.pivotRef.current.getWorldPosition(vec);
+ // Store simple xyz object or array to save memory/complexity
+ job.data[name].push(vec.toArray());
+ }
+ });
+
+ job.currentPos += job.increment;
+ batchCount++;
+ }
+
+ if (job.currentPos > job.endPos) {
+ setGeneratedData(job.data);
+ setGenerating(false);
+ }
+ });
+
+ return (
+ <>{showResult && generatedData && }>
+ );
+};
+
+export default PlotController;
diff --git a/src/components/Plot/PlotVisuals.jsx b/src/components/Plot/PlotVisuals.jsx
new file mode 100644
index 0000000..d791526
--- /dev/null
+++ b/src/components/Plot/PlotVisuals.jsx
@@ -0,0 +1,65 @@
+import { useLayoutEffect, useRef, useMemo } from "react";
+import { Object3D } from "three";
+import { useSettingsStore } from "../../store";
+
+const PlotVisuals = ({ data }) => {
+ const { settings } = useSettingsStore();
+
+ // Helper to get planet settings (color, size)
+ const getPlanetSetting = (name) => settings.find((s) => s.name === name);
+
+ return (
+
+ {Object.entries(data).map(([planetName, positions]) => {
+ const setting = getPlanetSetting(planetName);
+ if (!setting || positions.length === 0) return null;
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+const InstancedPlanet = ({ positions, color, size }) => {
+ const meshRef = useRef();
+ const dummy = useMemo(() => new Object3D(), []);
+
+ useLayoutEffect(() => {
+ if (!meshRef.current) return;
+
+ positions.forEach((posArray, i) => {
+ dummy.position.set(posArray[0], posArray[1], posArray[2]);
+ dummy.scale.set(1, 1, 1); // Keep scale 1, geometry controls size
+ dummy.updateMatrix();
+ meshRef.current.setMatrixAt(i, dummy.matrix);
+ });
+
+ meshRef.current.instanceMatrix.needsUpdate = true;
+ }, [positions, dummy]);
+
+ return (
+
+
+
+
+ );
+};
+
+export default PlotVisuals;
diff --git a/src/components/Plot/plotStore.js b/src/components/Plot/plotStore.js
new file mode 100644
index 0000000..1d7d6bb
--- /dev/null
+++ b/src/components/Plot/plotStore.js
@@ -0,0 +1,49 @@
+import { create } from "zustand";
+
+export const usePlotterStore = create((set) => ({
+ trigger: false,
+ params: null,
+
+ isGenerating: false,
+
+ showResult: false,
+ generatedData: null, // Will hold { PlanetName: [Vector3, Vector3...] }
+ generationError: null,
+
+ setGenerationParams: (params) =>
+ set({
+ trigger: true,
+ params,
+ showResult: false,
+ generatedData: null,
+ generationError: null,
+ isGenerating: true,
+ }),
+
+ resetTrigger: () => set({ trigger: false }),
+
+ setIsGenerating: (isGenerating) => set({ isGenerating }),
+
+ setGeneratedData: (data) =>
+ set({
+ generatedData: data,
+ showResult: true,
+ generationError: null,
+ isGenerating: false,
+ }),
+
+ setGenerationError: (error) =>
+ set({
+ generationError: error,
+ showResult: true,
+ generatedData: null,
+ isGenerating: false,
+ }),
+
+ clearResults: () =>
+ set({
+ showResult: false,
+ generatedData: null,
+ generationError: null,
+ }),
+}));
diff --git a/src/components/PlotSolarSystem.jsx b/src/components/PlotSolarSystem.jsx
index ac1d8a7..de1eea0 100644
--- a/src/components/PlotSolarSystem.jsx
+++ b/src/components/PlotSolarSystem.jsx
@@ -1,4 +1,4 @@
-import Pobj from "./Pobj";
+import Pobj from "./Pobj";
const PlotSolarSystem = () => {
return (
@@ -13,7 +13,7 @@ const PlotSolarSystem = () => {
-
+
@@ -28,6 +28,9 @@ const PlotSolarSystem = () => {
+
+
+
diff --git a/src/components/Recorder/RecorderController.jsx b/src/components/Recorder/RecorderController.jsx
new file mode 100644
index 0000000..057fc0c
--- /dev/null
+++ b/src/components/Recorder/RecorderController.jsx
@@ -0,0 +1,120 @@
+import { useEffect } from "react";
+import { useVideoCanvas } from "./r3f-video-recorder";
+import { useRecorderStore } from "./recorderStore";
+
+const RecorderController = () => {
+ const videoCanvas = useVideoCanvas();
+
+ useEffect(() => {
+ const unsub = useRecorderStore.subscribe((state, prevState) => {
+ if (state.command === "start" && state.command !== prevState.command) {
+ useRecorderStore.setState({
+ command: null,
+ status: "Initializing",
+ progress: 0,
+ errorMsg: "",
+ });
+
+ if (videoCanvas.recording) {
+ try {
+ videoCanvas.recording.cancel();
+ } catch (e) {}
+ videoCanvas.recording = null;
+ }
+
+ // 1. Set up an independent background loop to track progress reliably
+ const trackerInterval = setInterval(() => {
+ const rec = videoCanvas.recording;
+ if (!rec) return;
+
+ const currentStore = useRecorderStore.getState();
+
+ // Move from Initializing to Recording
+ if (
+ currentStore.status === "Initializing" &&
+ rec.status === "ready-for-frames"
+ ) {
+ useRecorderStore.setState({ status: "Recording" });
+ }
+
+ // Track the frames dynamically
+ if (
+ currentStore.status === "Recording" &&
+ rec.firstFrame !== null &&
+ rec.lastCapturedFrame !== null &&
+ rec.duration
+ ) {
+ const currentFrame = rec.lastCapturedFrame - rec.firstFrame + 1;
+ const totalFrames = rec.duration * rec.fps;
+ const pct = Math.max(
+ 0,
+ Math.min(100, Math.floor((currentFrame / totalFrames) * 100))
+ );
+
+ if (pct !== currentStore.progress) {
+ useRecorderStore.setState({ progress: pct });
+ }
+ if (pct >= 100) {
+ useRecorderStore.setState({ status: "Finalizing" });
+ }
+ }
+ }, 100);
+
+ // 2. Execute the recording
+ videoCanvas
+ .record({
+ mode: "frame-accurate",
+ duration: state.duration,
+ size: state.sizePreset,
+ codec: "avc",
+ })
+ .then((blob) => {
+ clearInterval(trackerInterval); // Clean up the tracker
+ useRecorderStore.setState({ status: "Ready", progress: 0 });
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `TSN-Capture-${state.sizePreset}-${state.duration}s.mp4`;
+
+ document.body.appendChild(a);
+ a.click();
+
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 150);
+ })
+ .catch((err) => {
+ clearInterval(trackerInterval); // Clean up the tracker
+ useRecorderStore.setState({
+ status: "Error",
+ errorMsg: String(err),
+ });
+ });
+ } else if (
+ state.command === "cancel" &&
+ state.command !== prevState.command
+ ) {
+ useRecorderStore.setState({
+ command: null,
+ status: "Ready",
+ progress: 0,
+ errorMsg: "",
+ });
+ if (videoCanvas.recording) {
+ try {
+ videoCanvas.recording.cancel();
+ } catch (e) {}
+ }
+ }
+ });
+
+ return unsub; // Cleanup subscription
+ }, [videoCanvas]);
+
+ return null;
+};
+
+export default RecorderController;
diff --git a/src/components/Recorder/r3f-video-recorder.jsx b/src/components/Recorder/r3f-video-recorder.jsx
new file mode 100644
index 0000000..7640f19
--- /dev/null
+++ b/src/components/Recorder/r3f-video-recorder.jsx
@@ -0,0 +1,422 @@
+"use client";
+
+import {
+ CanvasSource,
+ Output,
+ Mp4OutputFormat,
+ BufferTarget,
+ QUALITY_HIGH,
+} from "mediabunny";
+import {
+ createContext,
+ forwardRef,
+ useContext,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
+import { Canvas, useFrame, useThree } from "@react-three/fiber";
+import { action, makeObservable, observable } from "mobx";
+
+const EPSILON = 1e-7;
+
+function floor(n) {
+ return Math.floor(n + EPSILON);
+}
+
+function even(n) {
+ const rounded = Math.round(n);
+ return rounded & 1 ? rounded + 1 : rounded;
+}
+
+const SCALES = {
+ "1x": 1,
+ "2x": 2,
+ "3x": 3,
+ "4x": 4,
+};
+
+const VideoCanvasContext = createContext(null);
+
+export const useVideoCanvas = () => {
+ const canvas = useContext(VideoCanvasContext);
+ if (!canvas) {
+ throw new Error("Can only call useVideoCanvas inside of VideoCanvas");
+ }
+ return canvas;
+};
+
+export const VideoCanvas = forwardRef(
+ ({ fps, onCreated, children, ...otherProps }, ref) => {
+ const stateRef = useRef(null);
+ const videoCanvasRef = useRef(null);
+
+ const maybeNotifyCreated = () => {
+ if (stateRef.current && videoCanvasRef.current)
+ onCreated?.({
+ ...stateRef.current,
+ videoCanvas: videoCanvasRef.current,
+ });
+ };
+
+ return (
+ {
+ stateRef.current = state;
+ maybeNotifyCreated();
+ }}
+ >
+ {
+ videoCanvasRef.current = videoCanvas;
+ maybeNotifyCreated();
+ }}
+ fps={fps}
+ >
+ {children}
+
+
+ );
+ }
+);
+
+const VideoCanvasInner = forwardRef(({ fps, children }, ref) => {
+ const { gl, size } = useThree((state) => ({
+ gl: state.gl,
+ size: state.size,
+ }));
+ const [videoCanvas] = useState(() => new VideoCanvasManager(gl, { fps }));
+
+ useImperativeHandle(ref, () => videoCanvas);
+
+ useEffect(() => {
+ videoCanvas.setFps(fps);
+ }, [videoCanvas, fps]);
+
+ useFrame(({ gl, scene, camera, size }) => {
+ gl.setSize(even(size.width), even(size.height), false);
+ gl.render(scene, camera);
+ if (
+ videoCanvas.recording instanceof FrameAccurateVideoRecording &&
+ videoCanvas.recording.status === VideoRecordingStatus.ReadyForFrames &&
+ (videoCanvas.recording.lastCapturedFrame ?? -1) < videoCanvas.frame &&
+ !videoCanvas.recording.isCapturingFrame
+ ) {
+ videoCanvas.recording.captureFrame(videoCanvas.frame).then(() => {
+ videoCanvas.setFrame(videoCanvas.frame + 1);
+ });
+ } else if (
+ videoCanvas.recording instanceof RealtimeVideoRecording &&
+ videoCanvas.recording.status === VideoRecordingStatus.ReadyForFrames &&
+ (videoCanvas.recording.lastCapturedFrame ?? -1) < videoCanvas.frame &&
+ !videoCanvas.recording.isCapturingFrame
+ ) {
+ videoCanvas.recording.captureFrame(videoCanvas.frame);
+ }
+ }, 1);
+
+ return (
+
+ {children}
+
+ );
+});
+
+export class VideoCanvasManager {
+ constructor(gl, { fps = 60 } = {}) {
+ this.gl = gl;
+ this.fps = fps;
+ this.recording = null;
+ this.rawTime = 0;
+ this.isPlaying = false;
+ this.lastTimestamp = null;
+ this.rafId = null;
+
+ makeObservable(this, {
+ isPlaying: observable.ref,
+ rawTime: observable.ref,
+ recording: observable.ref,
+ fps: observable.ref,
+ setTime: action,
+ setFrame: action,
+ setFps: action,
+ play: action,
+ pause: action,
+ });
+
+ this.loop = action(() => {
+ if (!this.isPlaying) return;
+ const timestamp = performance.now();
+ const delta = timestamp - this.lastTimestamp;
+ this.lastTimestamp = timestamp;
+ this.rawTime += delta / 1000;
+ this.rafId = requestAnimationFrame(this.loop);
+ });
+ }
+
+ toFrame(time) {
+ return floor(time * this.fps);
+ }
+
+ toTime(frame) {
+ return frame / this.fps;
+ }
+
+ get time() {
+ return this.toTime(this.frame);
+ }
+
+ setTime(time) {
+ this.setFrame(this.toFrame(time));
+ }
+
+ get frame() {
+ return this.toFrame(this.rawTime);
+ }
+
+ setFrame(frame) {
+ this.rawTime = this.toTime(floor(frame));
+ }
+
+ setFps(fps) {
+ this.fps = fps;
+ }
+
+ play() {
+ this.isPlaying = true;
+ if (this.rafId === null) {
+ this.lastTimestamp = performance.now();
+ this.rafId = requestAnimationFrame(this.loop);
+ }
+ }
+
+ pause() {
+ this.isPlaying = false;
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ }
+
+ record({
+ mode,
+ duration,
+ format = new Mp4OutputFormat(),
+ codec = "avc",
+ size = "2x",
+ quality = QUALITY_HIGH,
+ }) {
+ return new Promise(async (resolve, reject) => {
+ const initialPixelRatio = this.gl.getPixelRatio();
+ this.gl.setPixelRatio(1 * SCALES[size]);
+ if (mode === "frame-accurate") {
+ this.pause();
+
+ // Wait 150ms for the WebGL buffer to finish resizing
+ setTimeout(() => {
+ this.recording = new FrameAccurateVideoRecording({
+ canvas: this.gl.domElement,
+ fps: this.fps,
+ duration,
+ format,
+ codec,
+ quality,
+ onDone: (blob) => {
+ this.pause();
+ resolve(blob);
+ this.recording = null;
+ this.gl.setPixelRatio(initialPixelRatio);
+ },
+ onError: (err) => {
+ this.pause();
+ reject(err);
+ this.recording = null;
+ this.gl.setPixelRatio(initialPixelRatio);
+ },
+ });
+ }, 150);
+ } else {
+ this.play();
+ // Wait 150ms for the WebGL buffer to finish resizing
+ setTimeout(() => {
+ this.recording = new RealtimeVideoRecording({
+ canvas: this.gl.domElement,
+ fps: this.fps,
+ duration,
+ format,
+ codec,
+ quality,
+ onDone: (blob) => {
+ this.pause();
+ resolve(blob);
+ this.recording = null;
+ this.gl.setPixelRatio(initialPixelRatio);
+ },
+ onError: (err) => {
+ this.pause();
+ reject(err);
+ this.recording = null;
+ this.gl.setPixelRatio(initialPixelRatio);
+ },
+ });
+ }, 150);
+ }
+ });
+ }
+}
+
+const VideoRecordingStatus = {
+ Initializing: "initializing",
+ ReadyForFrames: "ready-for-frames",
+ Finalizing: "finalizing",
+ Canceling: "canceling",
+};
+
+class VideoRecording {
+ constructor(params) {
+ this.canvas = params.canvas;
+ this.fps = params.fps;
+ this.format = params.format;
+ this.codec = params.codec;
+ this.quality = params.quality;
+ this.onDone = params.onDone;
+ this.onError = params.onError;
+
+ this.status = VideoRecordingStatus.Initializing;
+ this.firstFrame = null;
+ this.lastCapturedFrame = null;
+ this.isCapturingFrame = false;
+
+ this.output = new Output({
+ format: params.format,
+ target: new BufferTarget(),
+ });
+ this.canvasSource = new CanvasSource(this.canvas, {
+ codec: params.codec,
+ bitrate: params.quality,
+ });
+ this.output.addVideoTrack(this.canvasSource, { frameRate: this.fps });
+ this.output
+ .start()
+ .then(() => {
+ this.setStatus(VideoRecordingStatus.ReadyForFrames);
+ })
+ .catch((e) => {
+ this.canelWithReason(e || new Error("Unable to initialize recording"));
+ });
+
+ makeObservable(this, {
+ status: observable.ref,
+ setStatus: action,
+ });
+
+ this.stop = async () => {
+ try {
+ this.setStatus(VideoRecordingStatus.Finalizing);
+ this.canvasSource.close();
+ await this.output.finalize();
+ const buffer = this.output.target.buffer;
+ const blob = new Blob([buffer], {
+ type: this.output.format.mimeType,
+ });
+ this.onDone(blob);
+ } catch (err) {
+ this.canelWithReason(err);
+ }
+ };
+
+ this.canelWithReason = async (err = new Error("Recording canceled")) => {
+ try {
+ this.setStatus(VideoRecordingStatus.Canceling);
+ this.canvasSource.close();
+ await this.output.cancel();
+ this.onError(err);
+ } catch (err) {
+ this.onError(err);
+ }
+ };
+
+ this.cancel = async () => {
+ return this.canelWithReason(new Error("Recording canceled"));
+ };
+ }
+
+ toFrame(time) {
+ return floor(time * this.fps);
+ }
+
+ toTime(frame) {
+ return frame / this.fps;
+ }
+
+ captureFrame(frame) {
+ throw new Error("captureFrame must be implemented by subclasses");
+ }
+
+ setStatus(status) {
+ this.status = status;
+ }
+}
+
+class FrameAccurateVideoRecording extends VideoRecording {
+ constructor(params) {
+ super(params);
+ this.duration = params.duration;
+ }
+
+ async captureFrame(frame) {
+ try {
+ this.isCapturingFrame = true;
+ if (this.firstFrame === null) {
+ this.firstFrame = frame;
+ }
+ await this.canvasSource.add(
+ this.toTime(frame) - this.toTime(this.firstFrame),
+ this.toTime(1)
+ );
+ this.lastCapturedFrame = frame;
+ if (this.toTime(frame - this.firstFrame + 1) >= this.duration) {
+ await this.stop();
+ }
+ } catch (err) {
+ await this.canelWithReason(err);
+ } finally {
+ this.isCapturingFrame = false;
+ }
+ }
+}
+
+class RealtimeVideoRecording extends VideoRecording {
+ constructor(params) {
+ super(params);
+ this.duration = params.duration ?? null;
+ }
+
+ async captureFrame(frame) {
+ try {
+ this.isCapturingFrame = true;
+ if (this.firstFrame === null) {
+ this.firstFrame = frame;
+ }
+ await this.canvasSource.add(
+ this.toTime(frame) - this.toTime(this.firstFrame),
+ this.toTime(1)
+ );
+ this.lastCapturedFrame = frame;
+ if (
+ this.duration !== null &&
+ this.toTime(frame - this.firstFrame + 1) >= this.duration
+ ) {
+ await this.stop();
+ }
+ } catch (err) {
+ await this.canelWithReason(err);
+ } finally {
+ this.isCapturingFrame = false;
+ }
+ }
+}
diff --git a/src/components/Recorder/recorderStore.js b/src/components/Recorder/recorderStore.js
new file mode 100644
index 0000000..30d2ee4
--- /dev/null
+++ b/src/components/Recorder/recorderStore.js
@@ -0,0 +1,21 @@
+import { create } from "zustand";
+
+export const useRecorderStore = create((set) => ({
+ command: null,
+ setCommand: (cmd) => set({ command: cmd }),
+
+ status: "Ready",
+ setStatus: (status) => set({ status }),
+
+ progress: 0,
+ setProgress: (p) => set({ progress: p }),
+
+ errorMsg: "",
+ setErrorMsg: (msg) => set({ errorMsg: msg }),
+
+ duration: 10,
+ setDuration: (v) => set({ duration: v }),
+
+ sizePreset: "2x",
+ setSizePreset: (v) => set({ sizePreset: v }),
+}));
diff --git a/src/components/SolarSystem.jsx b/src/components/SolarSystem.jsx
index b0134db..e62c349 100644
--- a/src/components/SolarSystem.jsx
+++ b/src/components/SolarSystem.jsx
@@ -32,6 +32,9 @@ const SolarSystem = () => {
+
+
+
diff --git a/src/components/StarDataPanel/StarDataPanel.jsx b/src/components/StarDataPanel/StarDataPanel.jsx
index a6f08f3..b9b1aae 100644
--- a/src/components/StarDataPanel/StarDataPanel.jsx
+++ b/src/components/StarDataPanel/StarDataPanel.jsx
@@ -1,6 +1,7 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import { useStarDataStore } from "./starDataStore";
import starSettings from "../../settings/star-settings.json";
+
const starSettingsHRs = new Set(
starSettings.map((s) => String(s.HR)).filter(Boolean)
);
@@ -10,39 +11,43 @@ const StarDataPanel = () => {
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
+ // Continuously track the latest mouse coordinates
+ const mousePosRef = useRef({ x: 0, y: 0 });
+
useEffect(() => {
- if (!hoveredStar) return;
+ const trackMouse = (e) => {
+ mousePosRef.current = { x: e.clientX, y: e.clientY };
+ };
+ window.addEventListener("mousemove", trackMouse);
+ return () => window.removeEventListener("mousemove", trackMouse);
+ }, []);
- // console.log(starSettingsHRs)
+ useEffect(() => {
+ if (!hoveredStar) {
+ setVisible(false);
+ return;
+ }
- const handleInitialPosition = (event) => {
- const root = document.getElementById("root");
- let scale = 1;
+ // Grab the latest mouse position from our ref
+ const root = document.getElementById("root");
+ let scale = 1;
- if (root && root.style.transform) {
- const transformMatch = root.style.transform.match(/scale\(([^)]+)\)/);
- if (transformMatch) {
- scale = parseFloat(transformMatch[1]);
- }
+ if (root && root.style.transform) {
+ const transformMatch = root.style.transform.match(/scale\(([^)]+)\)/);
+ if (transformMatch) {
+ scale = parseFloat(transformMatch[1]);
}
+ }
- const adjustedX = event.clientX / scale;
- const adjustedY = event.clientY / scale;
+ const adjustedX = mousePosRef.current.x / scale;
+ const adjustedY = mousePosRef.current.y / scale;
- setPanelPosition({ x: adjustedX - 70, y: adjustedY + 20 });
+ setPanelPosition({ x: adjustedX - 70, y: adjustedY + 20 });
- document.removeEventListener("mousemove", handleInitialPosition);
- };
-
- document.addEventListener("mousemove", handleInitialPosition);
-
- // Delay fade-in until after position is set
- setTimeout(() => setVisible(true), 0);
+ // Delay fade-in slightly to ensure position applies first
+ const timer = setTimeout(() => setVisible(true), 10);
- return () => {
- document.removeEventListener("mousemove", handleInitialPosition);
- setVisible(false);
- };
+ return () => clearTimeout(timer);
}, [hoveredStar]);
// Check if the star should be ignored
@@ -50,7 +55,6 @@ const StarDataPanel = () => {
return null;
}
- // ✅ Always render, fade with opacity
return (
{
opacity: visible ? 0.8 : 0,
transition: "opacity 0.3s ease",
display: hoveredStar ? "block" : "none",
- transform: "scale(0.8)", // Scale to 80%
- transformOrigin: "top left", // Anchor scaling
+ transformOrigin: "top left",
}}
>
diff --git a/src/components/StarSearch/HighlightSelectedStar.jsx b/src/components/StarSearch/HighlightSelectedStar.jsx
index e566ab2..f9842e8 100644
--- a/src/components/StarSearch/HighlightSelectedStar.jsx
+++ b/src/components/StarSearch/HighlightSelectedStar.jsx
@@ -1,122 +1,212 @@
-import { useStore } from "../../store";
+import { useStore, useSettingsStore, useStarStore } from "../../store";
import { Html } from "@react-three/drei";
-import { useRef, useMemo } from "react";
+import { useRef, useMemo, useEffect } from "react";
+import { useThree, useFrame } from "@react-three/fiber";
import starsData from "../../settings/BSC.json";
-
+import specialStarsData from "../../settings/star-settings.json";
import { LABELED_STARS } from "../Stars/LabeledStars";
+import { getRaDecDistanceFromPosition } from "../../utils/celestial-functions";
const CROSSHAIR_SIZE = 40; // px
export default function HighlightSelectedStar() {
- const position = useStore((s) => s.selectedStarPosition);
+ const { scene } = useThree();
const selectedStarHR = useStore((s) => s.selectedStarHR);
+ const searchStars = useStore((s) => s.searchStars);
+ const selectedStarPosition = useStore((s) => s.selectedStarPosition);
const showLabels = useStore((s) => s.showLabels);
- const portalRef = useRef(document.body);
+ const setSelectedStarData = useStore((s) => s.setSelectedStarData);
- // Get the selected star data and compute the display name
- const starName = useMemo(() => {
+ const planetSettings = useSettingsStore((s) => s.settings);
+ const starSettings = useStarStore((s) => s.settings);
+
+ const groupRef = useRef();
+ const targetObjectRef = useRef(null);
+ const lastUpdateRef = useRef(0);
+
+ const specialStarDef = useMemo(() => {
if (!selectedStarHR) return null;
- const star = starsData.find((s) => s.HR?.toString() === selectedStarHR);
- if (!star) return null;
-
- // Apply the same naming logic: Name + HIP, or just HIP, or just HR
- if (star.N && star.HIP) {
- return `${star.N} / HIP ${star.HIP}`;
- } else if (star.N && star.HR) {
- return `${star.N} / HR ${star.HR}`;
- } else if (star.HIP) {
- return `HIP ${star.HIP}`;
- } else if (star.HR) {
- return `HR ${star.HR}`;
+ if (selectedStarHR.startsWith("Special:")) {
+ const name = selectedStarHR.replace("Special:", "");
+ return specialStarsData.find((s) => s.name === name);
}
- return "Unknown";
- }, [selectedStarHR]);
- const isLabeledStar = useMemo(() => {
- if (!selectedStarHR) return false;
- const star = starsData.find((s) => s.HR?.toString() === selectedStarHR);
- if (!star) return false;
+ if (selectedStarHR.startsWith("Planet:")) return null;
- return LABELED_STARS.some(
- (query) =>
- (star.N && star.N.toLowerCase() === query.toLowerCase()) ||
- star.HIP === query ||
- star.HR === query
+ return specialStarsData.find(
+ (s) => s.HR && String(s.HR) === String(selectedStarHR)
);
}, [selectedStarHR]);
- if (!position) return null;
+ const starName = useMemo(() => {
+ if (!selectedStarHR) return null;
+
+ if (selectedStarHR.startsWith("Planet:")) {
+ return selectedStarHR.replace("Planet:", "");
+ }
+
+ let targetHR = selectedStarHR;
+ if (specialStarDef && specialStarDef.HR) {
+ targetHR = String(specialStarDef.HR);
+ } else if (selectedStarHR.startsWith("Special:")) {
+ return specialStarDef ? specialStarDef.name : "Unknown";
+ }
+
+ const bscStar = starsData.find(
+ (s) => s.HR && String(s.HR) === String(targetHR)
+ );
+ if (bscStar) {
+ const n = specialStarDef?.name || bscStar.N;
+ if (n && bscStar.HIP) return `${n} / HIP ${bscStar.HIP}`;
+ if (n && bscStar.HR) return `${n} / HR ${bscStar.HR}`;
+ if (n) return n;
+ if (bscStar.HIP) return `HIP ${bscStar.HIP}`;
+ return `HR ${bscStar.HR}`;
+ }
+
+ if (specialStarDef) return specialStarDef.name;
+ return "Unknown";
+ }, [selectedStarHR, specialStarDef]);
+
+ useEffect(() => {
+ targetObjectRef.current = null;
+ if (!selectedStarHR) return;
+
+ let objName = null;
+
+ if (selectedStarHR.startsWith("Planet:")) {
+ objName = selectedStarHR.replace("Planet:", "");
+ } else if (specialStarDef) {
+ objName = specialStarDef.name;
+ }
+
+ if (objName) {
+ const obj = scene.getObjectByName(objName);
+ if (obj) targetObjectRef.current = obj;
+ }
+ }, [selectedStarHR, specialStarDef, scene]);
+
+ useEffect(() => {
+ if (!selectedStarHR) {
+ setSelectedStarData(null);
+ }
+ }, [selectedStarHR, setSelectedStarData]);
+
+ useFrame(() => {
+ if (!selectedStarHR || !groupRef.current) return;
+
+ if (targetObjectRef.current) {
+ targetObjectRef.current.getWorldPosition(groupRef.current.position);
+ } else if (selectedStarPosition) {
+ groupRef.current.position.copy(selectedStarPosition);
+ }
+
+ const now = performance.now();
+ if (now - lastUpdateRef.current > 100) {
+ lastUpdateRef.current = now;
+
+ const currentPos = groupRef.current.position;
+ let magnitude = "N/A";
+
+ if (specialStarDef) {
+ magnitude = specialStarDef.magnitude || specialStarDef.V;
+ } else {
+ const star = starsData.find(
+ (s) => String(s.HR) === String(selectedStarHR)
+ );
+ if (star) magnitude = star.V;
+ }
+ const { ra, dec, dist, elongation } = getRaDecDistanceFromPosition(
+ currentPos,
+ scene
+ );
+
+ setSelectedStarData({
+ ra,
+ dec,
+ dist,
+ elongation,
+ mag: magnitude,
+ });
+ }
+ });
+
+ const isPlanet = selectedStarHR?.startsWith("Planet:");
+ const pName = isPlanet ? selectedStarHR.replace("Planet:", "") : null;
+ const pSetting = planetSettings.find((s) => s.name === pName);
+ const isPlanetVisible = pSetting ? pSetting.visible : true;
+
+ const hideText = isPlanet && showLabels && isPlanetVisible;
+
+ if (!selectedStarHR || !searchStars) return null;
return (
-
-
+
- {/* Star name label above the crosshair */}
- {starName && !(showLabels && isLabeledStar) && (
+
+ {starName && !hideText && (
+
+ {starName}
+
+ )}
- {starName}
-
- )}
-
- {/* Bottom arm */}
-
- {/* Left arm */}
-
- {/* Right arm */}
-
-
-
+ />
+
+
+
+
+
);
}
diff --git a/src/components/StarSearch/StarSearch.jsx b/src/components/StarSearch/StarSearch.jsx
index 0725ba0..a174c24 100644
--- a/src/components/StarSearch/StarSearch.jsx
+++ b/src/components/StarSearch/StarSearch.jsx
@@ -1,39 +1,179 @@
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useState, useEffect, useMemo, useRef } from "react";
+import { createPortal } from "react-dom";
import starsData from "../../settings/BSC.json";
-import { useStore } from "../../store";
+import celestialData from "../../settings/celestial-settings.json";
+import specialStarsData from "../../settings/star-settings.json";
+import miscData from "../../settings/misc-settings.json";
+import { useStore, useSettingsStore, useStarStore } from "../../store";
import createCrosshairTexture from "../../utils/createCrosshairTexture";
import * as THREE from "three";
+import { FaSearch } from "react-icons/fa";
export default function StarSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
+ // --- Store State ---
+ const searchStars = useStore((s) => s.searchStars);
+ const setSearchStars = useStore((s) => s.setSearchStars);
+
const selectedStarHR = useStore((s) => s.selectedStarHR);
const setSelectedStarHR = useStore((s) => s.setSelectedStarHR);
+
+ const selectedStarData = useStore((s) => s.selectedStarData);
+
const officialStarDistances = useStore((s) => s.officialStarDistances);
const runIntro = useStore((s) => s.runIntro);
-
const cameraControlsRef = useStore((s) => s.cameraControlsRef);
- //setSelectedStarHR(null) on component mount
+ // --- Orbit Target Sync State ---
+ const cameraTarget = useStore((s) => s.cameraTarget);
+ const cameraUpdate = useStore((s) => s.cameraUpdate);
+ const planetCamera = useStore((s) => s.planetCamera);
+ const prevCameraUpdate = useRef(cameraUpdate);
+
+ // --- Dedicated Search Target Sync State (Planet Camera) ---
+ const searchTarget = useStore((s) => s.searchTarget);
+ const searchUpdate = useStore((s) => s.searchUpdate);
+ const prevSearchUpdate = useRef(searchUpdate);
+
+ // --- Dragging State ---
+ const [isDragging, setIsDragging] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+
+ // --- Initialization & Cleanup ---
+ useEffect(() => {
+ if (!searchStars) {
+ setQuery("");
+ setResults([]);
+ setSelectedStarHR(null);
+ }
+ }, [searchStars, setSelectedStarHR]);
+
useEffect(() => {
setQuery("");
setResults([]);
setSelectedStarHR(null);
+ }, [officialStarDistances, setSelectedStarHR]);
+
+ // --- Search Logic ---
+ const indexedObjects = useMemo(() => {
+ // 1. Prepare Special Stars First
+ const special = specialStarsData.map((star) => ({
+ ...star,
+ id: star.HR ? String(star.HR) : `Special:${star.name}`,
+ type: "special",
+ displayName: star.name,
+ HR_display: star.HR ? `HR ${star.HR}` : null,
+ N: star.name,
+ }));
+
+ const specialHRs = new Set(
+ special.filter((s) => s.HR).map((s) => String(s.HR))
+ );
+
+ // 2. BSC Stars
+ const bsc = starsData
+ .filter((star) => {
+ if (star.HR && specialHRs.has(String(star.HR))) {
+ return false;
+ }
+ return true;
+ })
+ .map((star) => ({
+ ...star,
+ id: star.HR ? String(star.HR) : `HIP-${star.HIP}`,
+ type: "star",
+ HR_display: star.HR ? `HR ${star.HR}` : null,
+ HIP_display: star.HIP ? `HIP ${star.HIP}` : null,
+ displayName: star.N || (star.HR ? `HR ${star.HR}` : `HIP ${star.HIP}`),
+ }));
+
+ // 3. Planets
+ const planets = celestialData
+ .filter(
+ (p) =>
+ !p.name.includes("deferent") &&
+ p.name !== "SystemCenter" &&
+ !p.name.includes("def")
+ )
+ .map((p) => {
+ const misc = miscData.find((m) => m.name === p.name);
+ return {
+ ...p,
+ ...misc,
+ id: `Planet:${p.name}`,
+ type: "planet",
+ displayName: p.name,
+ N: p.name,
+ };
+ });
+
+ return [...planets, ...special, ...bsc];
}, []);
- // If officialStarDistances changes (toggles), clear the search field and results
+ // --- Reusable Target Selection Logic ---
+ const triggerSelection = React.useCallback(
+ (targetStr) => {
+ // 1. Strict pass: Try an exact ID or Name match first
+ let obj = indexedObjects.find(
+ (o) => o.id === targetStr || o.name === targetStr
+ );
+
+ // 2. Loose pass: Fallback if exact match wasn't found
+ if (!obj) {
+ obj = indexedObjects.find(
+ (o) =>
+ (o.N && String(o.N) === targetStr) ||
+ (o.HR && String(o.HR) === targetStr) ||
+ (o.HIP && String(o.HIP) === targetStr) ||
+ (o.HIP && `HIP-${o.HIP}` === targetStr)
+ );
+ }
+
+ if (obj) {
+ if (obj.type === "planet") {
+ useSettingsStore
+ .getState()
+ .updateSetting({ name: obj.name, visible: true });
+ } else if (obj.type === "special") {
+ useStarStore
+ .getState()
+ .updateSetting({ name: obj.name, visible: true });
+ }
+
+ setSearchStars(true);
+ setSelectedStarHR(obj.id);
+
+ setQuery("");
+ setResults([]);
+ }
+ },
+ [indexedObjects, setSearchStars, setSelectedStarHR]
+ );
+
+ // --- Sync Search with Orbit Camera Double Click ---
useEffect(() => {
- setQuery("");
- setResults([]);
- setSelectedStarHR(null);
- }, [officialStarDistances]);
+ if (cameraUpdate > prevCameraUpdate.current) {
+ prevCameraUpdate.current = cameraUpdate;
+ if (!planetCamera && cameraTarget) {
+ let cleanTarget = String(cameraTarget).replace("BSCStarTarget_", "");
+ triggerSelection(cleanTarget);
+ }
+ }
+ }, [cameraUpdate, cameraTarget, planetCamera, triggerSelection]);
- const indexedStars = starsData.map((star) => ({
- ...star,
- HR_display: star.HR ? `HR ${star.HR}` : null,
- HIP_display: star.HIP ? `HIP ${star.HIP}` : null,
- }));
+ // --- Sync Search with Dedicated Search Target (Planet Camera) ---
+ useEffect(() => {
+ if (searchUpdate > prevSearchUpdate.current) {
+ prevSearchUpdate.current = searchUpdate;
+ if (searchTarget) {
+ let cleanTarget = String(searchTarget).replace("BSCStarTarget_", "");
+ triggerSelection(cleanTarget);
+ }
+ }
+ }, [searchUpdate, searchTarget, triggerSelection]);
const handleChange = (e) => {
const value = e.target.value.trim();
@@ -48,72 +188,67 @@ export default function StarSearch() {
const lower = value.toLowerCase();
let filtered = [];
- // Check for explicit HR number search (starts with "hr ")
if (lower.startsWith("hr ")) {
const hrQuery = value.slice(3).trim();
- filtered = indexedStars.filter((star) => star.HR && star.HR === hrQuery);
- }
- // Check for explicit HIP number search (starts with "hip ")
- else if (lower.startsWith("hip ")) {
+ filtered = indexedObjects.filter(
+ (obj) => obj.HR && String(obj.HR) === hrQuery
+ );
+ } else if (lower.startsWith("hip ")) {
const hipQuery = value.slice(4).trim();
- filtered = indexedStars.filter(
- (star) => star.HIP && star.HIP === hipQuery
+ filtered = indexedObjects.filter(
+ (obj) => obj.HIP && String(obj.HIP) === hipQuery
);
- }
- // General search across name, HR, and HIP
- else {
- const nameMatches = indexedStars.filter((star) =>
- star.N ? star.N.toLowerCase().includes(lower) : false
+ } else {
+ const nameMatches = indexedObjects.filter((obj) =>
+ obj.N ? obj.N.toLowerCase().includes(lower) : false
);
const digits = value.replace(/\D/g, "");
-
let hrMatches = [];
let hipMatches = [];
if (lower === "hr") {
- hrMatches = indexedStars.filter(
- (star) => star.HR || (star.N && star.N.toLowerCase().includes("hr"))
+ hrMatches = indexedObjects.filter(
+ (obj) => obj.HR || (obj.N && obj.N.toLowerCase().includes("hr"))
);
} else if (lower === "hip") {
- hipMatches = indexedStars.filter(
- (star) => star.HIP || (star.N && star.N.toLowerCase().includes("hip"))
+ hipMatches = indexedObjects.filter(
+ (obj) => obj.HIP || (obj.N && obj.N.toLowerCase().includes("hip"))
);
} else if (digits) {
- hrMatches = indexedStars.filter(
- (star) => star.HR && star.HR.includes(digits)
+ hrMatches = indexedObjects.filter(
+ (obj) => obj.HR && String(obj.HR).includes(digits)
);
- hipMatches = indexedStars.filter(
- (star) => star.HIP && star.HIP.includes(digits)
+ hipMatches = indexedObjects.filter(
+ (obj) => obj.HIP && String(obj.HIP).includes(digits)
);
}
const all = [...nameMatches, ...hrMatches, ...hipMatches];
- const unique = Array.from(new Set(all));
- filtered = unique;
+ filtered = Array.from(
+ new Map(all.map((item) => [item.id, item])).values()
+ );
}
- setResults(filtered);
+ setResults(filtered.slice(0, 50));
};
- const handleSelect = (star) => {
- setSelectedStarHR(star.HR);
- let displayText;
- if (star.N && star.HIP) {
- displayText = `${star.N} / HIP ${star.HIP}`;
- } else if (star.N && star.HR) {
- displayText = `${star.N} / HR ${star.HR}`;
- } else if (star.HIP) {
- displayText = `HIP ${star.HIP}`;
- } else if (star.HR) {
- displayText = `HR ${star.HR}`;
- } else {
- displayText = "Unknown";
+ const handleSelect = (obj) => {
+ // Force visibility for Planets and Special Stars
+ if (obj.type === "planet") {
+ useSettingsStore
+ .getState()
+ .updateSetting({ name: obj.name, visible: true });
+ } else if (obj.type === "special") {
+ useStarStore.getState().updateSetting({ name: obj.name, visible: true });
}
- setQuery(displayText);
+
+ setSelectedStarHR(obj.id);
+
+ // Clear input query on selection
+ setQuery("");
setResults([]);
- // Rotate camera around target to view star
setTimeout(() => {
const starPos = useStore.getState().selectedStarPosition;
if (!starPos || !cameraControlsRef?.current) return;
@@ -123,79 +258,181 @@ export default function StarSearch() {
controls.getTarget(target);
const currentDist = controls.camera.position.distanceTo(target);
-
- // Direction from star to target
const starToTarget = target.clone().sub(starPos).normalize();
-
- // Base position on line
const basePos = target
.clone()
.add(starToTarget.multiplyScalar(currentDist));
- // Offset downward so star appears higher
const up = new THREE.Vector3(0, 1, 0);
- const offset = up.multiplyScalar(currentDist * -0.07); // Adjust 0.2 for vertical position
-
+ const offset = up.multiplyScalar(currentDist * -0.07);
const newPos = basePos.sub(offset);
controls.setPosition(newPos.x, newPos.y, newPos.z, true);
}, 100);
};
- // Derive star info from selectedStarHR
- const selectedStar = useMemo(() => {
+ // --- Display Helpers ---
+ const selectedObject = useMemo(() => {
return selectedStarHR
- ? starsData.find((star) => star.HR?.toString() === selectedStarHR)
+ ? indexedObjects.find((obj) => obj.id === selectedStarHR)
: null;
- }, [selectedStarHR]);
-
- const hrHipString = useMemo(() => {
- if (!selectedStar) return "N/A";
- if (selectedStar.N && selectedStar.HIP) {
- return `${selectedStar.N} / HIP ${selectedStar.HIP}`;
- } else if (selectedStar.N && selectedStar.HR) {
- return `${selectedStar.N} / HR ${selectedStar.HR}`;
- } else if (selectedStar.HIP) {
- return `HIP ${selectedStar.HIP}`;
- } else if (selectedStar.HR) {
- return `HR ${selectedStar.HR}`;
+ }, [selectedStarHR, indexedObjects]);
+
+ const displayString = useMemo(() => {
+ if (!selectedObject) return "N/A";
+
+ if (selectedObject.type === "planet") {
+ return selectedObject.displayName;
+ }
+
+ if (selectedObject.type === "special" && selectedObject.displayName) {
+ return selectedObject.displayName;
+ }
+
+ if (selectedObject.N && selectedObject.HIP) {
+ return `${selectedObject.N} / HIP ${selectedObject.HIP}`;
+ } else if (selectedObject.N && selectedObject.HR) {
+ return `${selectedObject.N} / HR ${selectedObject.HR}`;
+ } else if (selectedObject.HIP) {
+ return `HIP ${selectedObject.HIP}`;
+ } else if (selectedObject.HR) {
+ return `HR ${selectedObject.HR}`;
}
+
return "Unknown";
- }, [selectedStar]);
+ }, [selectedObject]);
+
+ const crosshairImageSrc = useMemo(
+ () => createCrosshairTexture().image.toDataURL(),
+ []
+ );
+
+ // --- Dragging Logic ---
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!isDragging) return;
+ setPosition({
+ x: position.x + (e.clientX - dragStart.x),
+ y: position.y + (e.clientY - dragStart.y),
+ });
+ setDragStart({ x: e.clientX, y: e.clientY });
+ };
+
+ const handleMouseUp = () => setIsDragging(false);
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isDragging, dragStart, position]);
+
+ const handleMouseDown = (e) => {
+ if (e.target.closest(".popup-header")) {
+ setIsDragging(true);
+ setDragStart({ x: e.clientX, y: e.clientY });
+ }
+ };
- // Create crosshair texture once
- const crosshairTexture = useMemo(() => createCrosshairTexture(), []);
- const crosshairImageSrc = crosshairTexture.image.toDataURL(); // Convert to base64 src
+ if (runIntro || !searchStars) return null;
- return (
- !runIntro && (
+ return createPortal(
+
+
+
+ Search
+
+
+ {/* --- Custom Close Button matching Leva injects --- */}
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setSearchStars(false);
+ }}
+ style={{
+ cursor: "pointer",
+ color: "#8C92A4",
+ fontSize: "14px",
+ fontWeight: "bold",
+ padding: "4px",
+ marginRight: "-2px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#FFFFFF")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#8C92A4")}
+ title="Close Search"
+ >
+ ✕
+
+
+
+
e.target.select()}
- placeholder="Search stars by name/number"
+ placeholder="Search star or planet..."
style={{
- fontSize: "18px",
+ fontSize: "12px",
color: "#ffffff",
backgroundColor: "#374151",
- borderRadius: "0.25rem",
- padding: "0.5rem",
+ borderRadius: "4px",
+ padding: "6px 10px",
border: "none",
outline: "none",
- flexGrow: 1,
width: "100%",
boxSizing: "border-box",
+ marginBottom: results.length > 0 || selectedStarHR ? "8px" : "0",
}}
className="starSearch-input"
/>
@@ -205,34 +442,41 @@ export default function StarSearch() {
style={{
width: "100%",
backgroundColor: "#1f2937",
- borderRadius: "0 0 8px 8px",
- boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
- maxHeight: "200px",
+ borderRadius: "4px",
+ maxHeight: "180px",
overflowY: "auto",
- marginTop: "4px",
listStyle: "none",
padding: 0,
+ margin: 0,
}}
>
- {results.map((star, index) => {
- const parts = [];
- if (star.N) parts.push(star.N);
- if (star.HIP_display) parts.push(star.HIP_display);
- if (star.HR_display) parts.push(star.HR_display);
- const displayText =
- parts.length > 0 ? parts.join(" / ") : "Unknown";
-
+ {results.map((obj, index) => {
+ let displayText = obj.displayName;
+ if (obj.type === "star" || (obj.type === "special" && obj.HR)) {
+ const parts = [];
+ if (obj.N) parts.push(obj.N);
+ if (obj.HIP_display) parts.push(obj.HIP_display);
+ if (obj.HR_display) parts.push(obj.HR_display);
+ displayText = parts.length > 0 ? parts.join(" / ") : "Unknown";
+ }
return (
handleSelect(star)}
+ onClick={() => handleSelect(obj)}
style={{
- padding: "10px",
- color: "#fff",
- fontSize: "18px",
+ padding: "6px 10px",
+ color: "#d1d5db",
+ fontSize: "12px",
cursor: "pointer",
- borderBottom: "1px solid #444",
+ borderBottom:
+ index < results.length - 1 ? "1px solid #374151" : "none",
}}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor = "#374151")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = "transparent")
+ }
>
{displayText}
@@ -240,16 +484,16 @@ export default function StarSearch() {
})}
)}
- {/* Selected star info */}
+
{selectedStarHR && (
@@ -258,17 +502,96 @@ export default function StarSearch() {
src={crosshairImageSrc}
alt="Crosshair"
style={{
- width: "40px",
- height: "40px",
- filter: "brightness(2.5) drop-shadow(0 0 6px yellow)",
+ width: "28px",
+ height: "28px",
+ filter: "brightness(2.5) drop-shadow(0 0 4px yellow)",
flexShrink: 0,
}}
/>
-
{hrHipString}
+
+
+ SELECTED
+
+
+ {displayString}
+ {selectedObject?.type === "planet" &&
+ selectedObject.unicodeSymbol && (
+
+ )}
+
+
+
+ {selectedStarData && (
+
+
+ RA:
+
+ {selectedStarData.ra}
+
+
+
+ Dec:
+
+ {selectedStarData.dec}
+
+
+
+ Distance:
+ {selectedStarData.dist}
+
+
+ Elongation:
+ {selectedStarData.elongation}
+
+ {selectedStarData.mag !== "N/A" &&
+ selectedStarData.mag !== undefined && (
+
+ Magnitude:
+ {selectedStarData.mag}
+
+ )}
+
+ )}
)}
- )
+ ,
+ document.body
);
}
diff --git a/src/components/Stars/BSCStars.jsx b/src/components/Stars/BSCStars.jsx
index a451bb0..1d19c8e 100644
--- a/src/components/Stars/BSCStars.jsx
+++ b/src/components/Stars/BSCStars.jsx
@@ -1,362 +1,111 @@
-import { useRef, useMemo, useState, useEffect } from "react";
+// src/components/Stars/BSCStars.jsx
+import { useRef, useState, useEffect, useCallback } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Vector3, Quaternion } from "three";
-import { usePlotStore } from "../../store";
+import { usePlotStore, useStore } from "../../store";
import bscSettings from "../../settings/BSC.json";
+import specialStarsData from "../../settings/star-settings.json";
import { dateTimeToPos } from "../../utils/time-date-functions";
-import { useStore } from "../../store";
-import {
- declinationToRadians,
- rightAscensionToRadians,
- sphericalToCartesian,
-} from "../../utils/celestial-functions";
-
import { movePlotModel } from "../../utils/plotModelFunctions";
-
-import colorTemperature2rgb from "../../utils/colorTempToRGB";
-
-import { pointShaderMaterial, pickingShaderMaterial } from "./starShaders";
-
+import { pointShaderMaterial } from "./starShaders";
import { LABELED_STARS } from "./LabeledStars";
+import { useBSCStarData } from "./useBSCStarData";
+import Star from "./Star";
-function createCircleTexture() {
- const size = 256;
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const context = canvas.getContext("2d");
- context.beginPath();
- context.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
- context.fillStyle = "white";
- context.fill();
- const texture = new THREE.Texture(canvas);
- texture.needsUpdate = true;
- return texture;
-}
+// Cache vectors outside the component to prevent garbage collection stutters
+const _v1 = new THREE.Vector3();
+const _worldPos = new THREE.Vector3();
const BSCStars = ({ onStarClick, onStarHover }) => {
const pointsRef = useRef();
const starGroupRef = useRef();
- const pickingPointsRef = useRef();
- const pickingRenderTarget = useRef();
+ const targetGroupRef = useRef(null);
+ const hiddenStarIndexRef = useRef(null);
+ const hiddenStarSizeRef = useRef(null);
- // FIX: Use useState to create a stable scene instance that persists across renders
- const [pickingScene] = useState(() => new THREE.Scene());
+ const [targetedStarData, setTargetedStarData] = useState(null);
+
+ // Access imperative R3F state to get real-time mouse/camera without re-renders
+ const getThreeState = useThree((state) => state.get);
- const colorMap = useRef(new Map());
- const [hoveredPoint, setHoveredPoint] = useState(null);
- const { scene, camera, gl } = useThree();
const plotObjects = usePlotStore((s) => s.plotObjects);
- const lastHoverTime = useRef(0);
const currentHoverIndex = useRef(null);
- const debugPicking = useRef(false);
-
- const officialStarDistances = useStore((s) => s.officialStarDistances);
- const starDistanceModifier = useStore((s) => s.starDistanceModifier);
- const hScale = useStore((s) => s.hScale);
- const starScale = useStore((s) => s.starScale);
- const starPickingSensitivity = useStore((s) => s.starPickingSensitivity);
+ const currentHoverDataRef = useRef(null);
const selectedStarHR = useStore((s) => s.selectedStarHR);
const setSelectedStarPosition = useStore((s) => s.setSelectedStarPosition);
+ const setLabeledStarPosition = useStore((s) => s.setLabeledStarPosition);
+ const cameraTarget = useStore((s) => s.cameraTarget);
- const planetCamera = useStore((s) => s.planetCamera);
+ const { positions, colors, starData, sizes } = useBSCStarData();
- const setLabeledStarPosition = useStore((s) => s.setLabeledStarPosition);
+ // Handle Target/Focus Reset
+ useEffect(() => {
+ if (!cameraTarget || !cameraTarget.startsWith("BSCStarTarget")) {
+ setTargetedStarData(null);
+ if (hiddenStarIndexRef.current !== null && pointsRef.current) {
+ const oldIndex = hiddenStarIndexRef.current;
+ pointsRef.current.geometry.attributes.size.array[oldIndex] =
+ hiddenStarSizeRef.current;
+ pointsRef.current.geometry.attributes.size.needsUpdate = true;
+ hiddenStarIndexRef.current = null;
+ }
+ }
+ }, [cameraTarget, sizes]);
+ // Handle Search Selection
useEffect(() => {
- //Used by StarSearch
+ if (selectedStarHR) {
+ const isSpecial = specialStarsData.some(
+ (s) =>
+ (s.HR && String(s.HR) === selectedStarHR) ||
+ selectedStarHR.startsWith("Special:")
+ );
+ if (isSpecial) return;
+ }
+
if (selectedStarHR && starData.length > 0 && pointsRef.current) {
const star = starData.find(
(s) => parseInt(s.HR) === parseInt(selectedStarHR)
);
- // console.log(starData);
if (!star) {
- console.warn(`Star with HR ${selectedStarHR} not found.`);
- setSelectedStarPosition(null);
+ if (!selectedStarHR.includes(":")) setSelectedStarPosition(null);
return;
}
-
const starIndex = star.index;
-
- const positions =
- pickingPointsRef.current.geometry.attributes.position.array;
- const x = positions[starIndex * 3];
- const y = positions[starIndex * 3 + 1];
- const z = positions[starIndex * 3 + 2];
-
- // Create a Vector3 and transform to world space
- const pos = new THREE.Vector3(x, y, z);
- pickingPointsRef.current.localToWorld(pos);
- // console.log("Selected star position:", pos);
-
+ const posArray = pointsRef.current.geometry.attributes.position.array;
+ const pos = new THREE.Vector3(
+ posArray[starIndex * 3],
+ posArray[starIndex * 3 + 1],
+ posArray[starIndex * 3 + 2]
+ );
+ pointsRef.current.localToWorld(pos);
setSelectedStarPosition(pos);
- } else {
+ } else if (!selectedStarHR) {
setSelectedStarPosition(null);
}
- }, [selectedStarHR]);
-
- // Memoize star attributes with selective picking sensitivity
- const { positions, colors, sizes, pickingSizes, starData, pickingColors } =
- useMemo(() => {
- const positions = [];
- const colors = [];
- const pickingColors = [];
- const sizes = [];
- const pickingSizes = [];
- const starData = [];
- const scale = 0.1;
-
- // Clear previous color mapping
- colorMap.current.clear();
-
- // Iterate over BSC.json
- bscSettings.forEach((s, index) => {
- // Parse string fields to numbers
- const magnitude = parseFloat(s.V);
- const colorTemp = parseFloat(s.K) || 5778;
-
- const raRad = rightAscensionToRadians(s.RA);
- const decRad = declinationToRadians(s.Dec);
-
- const distLy = parseFloat(s.P) * 3.26156378;
- let dist;
- if (!officialStarDistances) {
- dist = (20000 * hScale) / 100;
- } else {
- const worldDist = distLy * 63241 * 100;
- dist =
- worldDist / (starDistanceModifier >= 1 ? starDistanceModifier : 1);
- }
-
- const { x, y, z } = sphericalToCartesian(raRad, decRad, dist);
- positions.push(x, y, z);
-
- const { red, green, blue } = colorTemperature2rgb(colorTemp, true);
- colors.push(red, green, blue);
-
- // Generate unique picking color for this star
- const colorIndex = index + 1; // Start from 1 to avoid black
- const r = (colorIndex & 0xff) / 255;
- const g = ((colorIndex >> 8) & 0xff) / 255;
- const b = ((colorIndex >> 16) & 0xff) / 255;
-
- pickingColors.push(r, g, b);
-
- // Create hex color for lookup
- const rInt = Math.round(r * 255);
- const gInt = Math.round(g * 255);
- const bInt = Math.round(b * 255);
- const hexColor = (rInt << 16) | (gInt << 8) | bInt;
-
- colorMap.current.set(hexColor, index);
-
- // Size based on magnitude
- let starsize;
- if (magnitude < 1) {
- starsize = 1.2;
- } else if (magnitude > 1 && magnitude < 3) {
- starsize = 0.6;
- } else if (magnitude > 3 && magnitude < 5) {
- starsize = 0.4;
- } else {
- starsize = 0.2;
- }
-
- const visualSize = starsize * starScale * 10;
-
- // Apply picking sensitivity only to dim stars (magnitude 3+)
- let pickingSize;
- if (magnitude >= 3) {
- // Use sensitivity multiplier for dim stars
- pickingSize = visualSize * starPickingSensitivity;
- } else {
- // Bright stars keep their visual size for picking
- pickingSize = visualSize;
- }
-
- sizes.push(visualSize);
- pickingSizes.push(pickingSize);
-
- // Store metadata for mouseover
- starData.push({
- // Show name and HIP, or just HIP, or just HR:
- name: (() => {
- if (s.N && s.HIP) {
- return `${s.N} / HIP ${s.HIP}`;
- } else if (s.HIP) {
- return `HIP ${s.HIP}`;
- } else if (s.HR) {
- return `HR ${s.HR}`;
- }
- return "Unknown";
- })(),
- HR: s.HR,
- magnitude: isNaN(magnitude) ? 5 : magnitude,
- colorTemp,
- ra: s.RA,
- dec: s.Dec,
- distLy,
- index: index,
- });
- });
-
- return {
- positions: new Float32Array(positions),
- colors: new Float32Array(colors),
- pickingColors: new Float32Array(pickingColors),
- sizes: new Float32Array(sizes),
- pickingSizes: new Float32Array(pickingSizes),
- starData,
- };
- }, [
- officialStarDistances,
- hScale,
- starDistanceModifier,
- starScale,
- starPickingSensitivity,
- ]);
-
- // Initialize picking setup
- useEffect(() => {
- if (!gl || !camera) return;
-
- // Create picking render target
- pickingRenderTarget.current = new THREE.WebGLRenderTarget(1, 1);
- pickingRenderTarget.current.samples = 0; // Disable anti-aliasing
-
- // Update render target size
- const updateRenderTarget = () => {
- const { width, height } = gl.domElement;
- pickingRenderTarget.current.setSize(width, height);
- };
- window.addEventListener("resize", updateRenderTarget);
- updateRenderTarget();
-
- // Add event listener
- const canvas = gl.domElement;
- canvas.addEventListener("mousemove", handleHover);
-
- return () => {
- window.removeEventListener("resize", updateRenderTarget);
- canvas.removeEventListener("mousemove", handleHover);
- if (pickingRenderTarget.current) {
- pickingRenderTarget.current.dispose();
- }
- };
- }, [gl, camera, planetCamera]);
-
- // Handle hover (throttled)
- const handleHover = (event) => {
- if (!pickingPointsRef.current || !pickingRenderTarget.current) return;
-
- const now = performance.now();
- if (now - lastHoverTime.current < 300) return; // Throttle to 10Hz
- lastHoverTime.current = now;
-
- const { clientX, clientY } = event;
- const { width, height } = gl.domElement;
- const rect = gl.domElement.getBoundingClientRect();
-
- const x = Math.round((clientX - rect.left) * (width / rect.width));
- const y = Math.round((clientY - rect.top) * (height / rect.height));
-
- gl.setRenderTarget(pickingRenderTarget.current);
- gl.clear();
- // FIX: Use pickingScene directly (not .current)
- gl.render(pickingScene, camera);
- gl.setRenderTarget(null);
-
- if (debugPicking.current) {
- gl.render(pickingScene, camera);
- }
+ }, [selectedStarHR, starData, setSelectedStarPosition]);
- const pixelBuffer = new Uint8Array(4);
- gl.readRenderTargetPixels(
- pickingRenderTarget.current,
- x,
- height - y,
- 1,
- 1,
- pixelBuffer
- );
-
- const hexColor =
- (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];
- const starIndex = colorMap.current.get(hexColor);
-
- if (starIndex !== undefined) {
- if (currentHoverIndex.current !== starIndex) {
- currentHoverIndex.current = starIndex;
- setHoveredPoint(starIndex);
-
- const star = starData[starIndex];
- // Get the position attribute
- const positions =
- pickingPointsRef.current.geometry.attributes.position.array;
-
- // Get the specific point's local position
- const x = positions[starIndex * 3];
- const y = positions[starIndex * 3 + 1];
- const z = positions[starIndex * 3 + 2];
-
- // Create a Vector3 and transform to world space
- const worldPosition = new THREE.Vector3(x, y, z);
- pickingPointsRef.current.localToWorld(worldPosition);
-
- onStarHover({ star, position: worldPosition, index: starIndex }, event);
- }
- } else {
- if (currentHoverIndex.current !== null) {
- currentHoverIndex.current = null;
- setHoveredPoint(null);
- if (onStarHover) {
- onStarHover(null);
- }
- }
- }
- };
-
- // Update buffer attributes when positions, sizes, or picking sensitivity change
+ // Sync Attributes to Geometry
useEffect(() => {
if (pointsRef.current) {
- const geometry = pointsRef.current.geometry;
- geometry.setAttribute(
- "position",
- new THREE.BufferAttribute(positions, 3)
- );
- geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
- geometry.setAttribute("size", new THREE.BufferAttribute(sizes, 1));
- geometry.attributes.position.needsUpdate = true;
- geometry.attributes.color.needsUpdate = true;
- geometry.attributes.size.needsUpdate = true;
- }
+ const geo = pointsRef.current.geometry;
+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
+ geo.setAttribute("size", new THREE.BufferAttribute(sizes, 1));
- if (pickingPointsRef.current) {
- const geometry = pickingPointsRef.current.geometry;
- geometry.setAttribute(
- "position",
- new THREE.BufferAttribute(positions, 3)
- );
- geometry.setAttribute(
- "color",
- new THREE.BufferAttribute(pickingColors, 3)
- );
- geometry.setAttribute("size", new THREE.BufferAttribute(pickingSizes, 1));
- geometry.attributes.position.needsUpdate = true;
- geometry.attributes.color.needsUpdate = true;
- geometry.attributes.size.needsUpdate = true;
+ geo.computeBoundingBox();
+ geo.computeBoundingSphere();
}
- }, [positions, colors, pickingColors, sizes, pickingSizes]);
+ }, [positions, colors, sizes]);
- // Update picking scene when star group transforms
+ // Apply Geocentric rotations
useEffect(() => {
if (plotObjects.length > 0 && starGroupRef.current) {
const epochJ2000Pos = dateTimeToPos("2000-01-01", "12:00:00");
const worldPosition = new Vector3();
const worldQuaternion = new Quaternion();
-
movePlotModel(plotObjects, epochJ2000Pos);
const earthObj = plotObjects.find((p) => p.name === "Earth");
earthObj.cSphereRef.current.getWorldPosition(worldPosition);
@@ -364,139 +113,227 @@ const BSCStars = ({ onStarClick, onStarHover }) => {
starGroupRef.current.position.copy(worldPosition);
starGroupRef.current.quaternion.copy(worldQuaternion);
-
- // Update picking scene transformation
- // FIX: Use pickingScene directly (not .current)
- if (pickingPointsRef.current && pickingScene) {
- pickingPointsRef.current.position.copy(worldPosition);
- pickingPointsRef.current.quaternion.copy(worldQuaternion);
- }
}
- }, [plotObjects, pickingScene]);
+ }, [plotObjects]);
- // Add picking points to picking scene
+ // Update Labeled Stars
useEffect(() => {
- // FIX: Use pickingScene directly (not .current) and check for existence
- if (pickingPointsRef.current && pickingScene) {
- pickingScene.add(pickingPointsRef.current);
- return () => {
- if (pickingScene && pickingPointsRef.current) {
- pickingScene.remove(pickingPointsRef.current);
- }
- };
- }
- }, [pickingPointsRef.current, pickingScene]);
-
- useEffect(() => {
- if (starData.length === 0 || !pickingPointsRef.current) return;
-
+ if (starData.length === 0 || !pointsRef.current) return;
LABELED_STARS.forEach((query) => {
- const bscIndex = bscSettings.findIndex(
+ const bscStar = bscSettings.find(
(s) =>
- (s.N && s.N.toLowerCase() === query.toLowerCase()) ||
+ s.N?.toLowerCase() === query.toLowerCase() ||
s.HIP === query ||
s.HR === query
);
-
- if (bscIndex === -1) return;
-
- const bscStar = bscSettings[bscIndex];
+ if (!bscStar) return;
const star = starData.find(
(s) => parseInt(s.HR) === parseInt(bscStar.HR)
);
if (!star) return;
- const starIndex = star.index;
+ const posArray = pointsRef.current.geometry.attributes.position.array;
+ const pos = new THREE.Vector3(
+ posArray[star.index * 3],
+ posArray[star.index * 3 + 1],
+ posArray[star.index * 3 + 2]
+ );
+ pointsRef.current.localToWorld(pos);
+ setLabeledStarPosition(
+ bscStar.HR,
+ pos,
+ bscStar.N || (bscStar.HIP ? `HIP ${bscStar.HIP}` : `HR ${bscStar.HR}`)
+ );
+ });
+ }, [starData, setLabeledStarPosition, plotObjects]);
- const positions =
- pickingPointsRef.current.geometry.attributes.position.array;
- const x = positions[starIndex * 3];
- const y = positions[starIndex * 3 + 1];
- const z = positions[starIndex * 3 + 2];
+ // 💥 THE FIX: Screen-Space Raycaster Override
+ const customRaycast = useCallback(
+ (raycaster, intersects) => {
+ if (!pointsRef.current || useStore.getState().runIntro) return;
- const pos = new THREE.Vector3(x, y, z);
- pickingPointsRef.current.localToWorld(pos);
+ const { camera, size, pointer } = getThreeState();
+ const geometry = pointsRef.current.geometry;
+ const posArray = geometry.attributes.position.array;
+ const matrixWorld = pointsRef.current.matrixWorld;
+
+ // Define hover radius in CSS screen pixels (Adjust this up or down for feel)
+ const HOVER_RADIUS_PX = 4;
+ const thresholdSqPx = HOVER_RADIUS_PX * HOVER_RADIUS_PX;
+
+ // Map R3F NDC pointer to physical screen pixels
+ const pointerPxX = ((pointer.x + 1) / 2) * size.width;
+ const pointerPxY = ((-pointer.y + 1) / 2) * size.height;
+
+ for (let i = 0; i < posArray.length / 3; i++) {
+ _v1.set(posArray[i * 3], posArray[i * 3 + 1], posArray[i * 3 + 2]);
+ _v1.applyMatrix4(matrixWorld);
+
+ // Save the true 3D world position before modifying the vector
+ _worldPos.copy(_v1);
+
+ // Project the 3D star to 2D screen space
+ _v1.project(camera);
+
+ // Skip if the star is behind the camera or totally off screen
+ if (_v1.z > 1 || _v1.z < -1) continue;
+ if (_v1.x < -1.2 || _v1.x > 1.2 || _v1.y < -1.2 || _v1.y > 1.2)
+ continue;
+
+ // Convert star to physical screen pixels
+ const starPxX = ((_v1.x + 1) / 2) * size.width;
+ const starPxY = ((-_v1.y + 1) / 2) * size.height;
+
+ // Calculate 2D pixel distance to the mouse
+ const distSq =
+ (starPxX - pointerPxX) ** 2 + (starPxY - pointerPxY) ** 2;
+
+ // If the mouse is within our exact pixel radius, it's a hit!
+ if (distSq < thresholdSqPx) {
+ const distance3D = raycaster.ray.origin.distanceTo(_worldPos);
+
+ intersects.push({
+ distance: distance3D, // True 3D distance ensures R3F sorts overlapping objects correctly
+ distanceToRay: Math.sqrt(distSq),
+ point: _worldPos.clone(),
+ index: i,
+ face: null,
+ object: pointsRef.current,
+ });
+ }
+ }
+ },
+ [getThreeState]
+ );
- let displayName =
- bscStar.N || (bscStar.HIP ? `HIP ${bscStar.HIP}` : `HR ${bscStar.HR}`);
+ // --- NATIVE R3F POINTER EVENTS ---
- setLabeledStarPosition(bscStar.HR, pos, displayName);
- });
+ const handlePointerMove = (e) => {
+ e.stopPropagation();
- return () => {
- LABELED_STARS.forEach((query) => {
- const bscIndex = bscSettings.findIndex(
- (s) =>
- (s.N && s.N.toLowerCase() === query.toLowerCase()) ||
- s.HIP === query ||
- s.HR === query
- );
-
- if (bscIndex !== -1) {
- const bscStar = bscSettings[bscIndex];
- // Passing null removes the label from the store
- setLabeledStarPosition(bscStar.HR, null);
- }
+ const starIndex = e.index;
+
+ if (starIndex !== undefined && currentHoverIndex.current !== starIndex) {
+ currentHoverIndex.current = starIndex;
+ const star = starData[starIndex];
+
+ const worldPosition = new THREE.Vector3(
+ positions[starIndex * 3],
+ positions[starIndex * 3 + 1],
+ positions[starIndex * 3 + 2]
+ );
+ if (pointsRef.current) pointsRef.current.localToWorld(worldPosition);
+
+ const hoverData = { star, position: worldPosition, index: starIndex };
+ currentHoverDataRef.current = hoverData;
+
+ if (onStarHover) onStarHover(hoverData, e);
+ }
+ };
+
+ const handlePointerOut = (e) => {
+ e.stopPropagation();
+ currentHoverIndex.current = null;
+ currentHoverDataRef.current = null;
+ if (onStarHover) onStarHover(null);
+ };
+
+ const handleClick = (e) => {
+ e.stopPropagation();
+ if (useStore.getState().runIntro) return;
+ if (currentHoverDataRef.current && onStarClick) {
+ onStarClick(currentHoverDataRef.current, e);
+ }
+ };
+
+ const handleDoubleClick = (e) => {
+ e.stopPropagation();
+ if (useStore.getState().runIntro) return;
+ // --- Planet Camera Intercept ---
+ if (useStore.getState().planetCamera) {
+ if (currentHoverDataRef.current) {
+ useStore
+ .getState()
+ .setSearchTarget(String(currentHoverDataRef.current.star.HR));
+ }
+ return; // Exit early to prevent Orbit target cloning
+ }
+
+ if (currentHoverDataRef.current && targetGroupRef.current) {
+ const { index, star } = currentHoverDataRef.current;
+
+ if (hiddenStarIndexRef.current !== null && pointsRef.current) {
+ const oldIndex = hiddenStarIndexRef.current;
+ pointsRef.current.geometry.attributes.size.array[oldIndex] =
+ hiddenStarSizeRef.current;
+ pointsRef.current.geometry.attributes.size.needsUpdate = true;
+ }
+
+ hiddenStarIndexRef.current = index;
+ if (pointsRef.current) {
+ // FIX: Save the true size before shrinking it
+ hiddenStarSizeRef.current =
+ pointsRef.current.geometry.attributes.size.array[index];
+ pointsRef.current.geometry.attributes.size.array[index] = 0;
+ pointsRef.current.geometry.attributes.size.needsUpdate = true;
+ }
+
+ targetGroupRef.current.position.set(
+ positions[index * 3],
+ positions[index * 3 + 1],
+ positions[index * 3 + 2]
+ );
+
+ const r = colors[index * 3];
+ const g = colors[index * 3 + 1];
+ const b = colors[index * 3 + 2];
+ const hexColor = "#" + new THREE.Color(r, g, b).getHexString();
+
+ const targetName = `BSCStarTarget_${star.HR}`;
+ targetGroupRef.current.name = targetName;
+
+ setTargetedStarData({
+ ...star,
+ isTargetClone: true,
+ visible: true,
+ magnitude: star.mag ?? star.magnitude ?? star.Mag ?? 3,
+ overrideColor: hexColor,
});
- };
- }, [starData, setLabeledStarPosition, plotObjects]);
+ if (onStarHover) onStarHover(null);
+ currentHoverIndex.current = null;
+ currentHoverDataRef.current = null;
+ useStore.getState().setCameraTarget(targetName);
+ }
+ };
return (
- <>
-
- {/* Visible stars */}
-
-
-
-
-
-
-
-
+
+
+ {targetedStarData && }
- {/* Picking stars with selective sensitivity for dim stars */}
-
-
-
-
-
-
-
+ {/* Attach the custom raycaster directly to the mesh */}
+
+
+
- >
+
);
};
diff --git a/src/components/Stars/LabeledStars.jsx b/src/components/Stars/LabeledStars.jsx
index ce236ca..7d481ef 100644
--- a/src/components/Stars/LabeledStars.jsx
+++ b/src/components/Stars/LabeledStars.jsx
@@ -1,51 +1,53 @@
-import { useRef } from "react";
import { Html } from "@react-three/drei";
import { useStore } from "../../store";
+import labeledStarsData from "../../settings/labeled-stars.json";
-export const LABELED_STARS = [
- "Polaris",
- "Sirius",
- "Procyon",
- "Deneb Algedi",
- "Betelgeuse",
- "Rigel",
- "Canopus",
- "Vega",
- "Thuban",
- "Capella",
- "Altair",
- "Aldebaran",
- "Antares",
- "Arcturus",
- "Achernar",
- "Polaris Australis",
- "Hadar",
-];
+export const LABELED_STARS = labeledStarsData;
export default function LabeledStars() {
const showLabels = useStore((s) => s.showLabels);
const runIntro = useStore((s) => s.runIntro);
const labeledStarPositions = useStore((s) => s.labeledStarPositions);
+ const bscVisible = useStore((s) => s.BSCStars);
- if (runIntro || !showLabels) return null;
+ const selectedStarHR = useStore((s) => s.selectedStarHR);
+ const cameraTarget = useStore((s) => s.cameraTarget);
+
+ // Extract the HR number if the camera is currently targeting a point cloud clone
+ const targetedHR = cameraTarget?.startsWith("BSCStarTarget_")
+ ? cameraTarget.split("_")[1]
+ : null;
+
+ if (runIntro || !showLabels || !bscVisible) return null;
return (
<>
- {Array.from(labeledStarPositions.values()).map((star) => (
-
- {
+ // Destroy the static label if the star is actively searched OR targeted by the camera
+ if (
+ String(hr) === String(selectedStarHR) ||
+ String(hr) === String(targetedHR)
+ ) {
+ return null;
+ }
+
+ return (
+
- {star.name}
-
-
- ))}
+
+ {star.name}
+
+
+ );
+ })}
>
);
}
diff --git a/src/components/Stars/Star.jsx b/src/components/Stars/Star.jsx
index a152f7e..e9d9a31 100644
--- a/src/components/Stars/Star.jsx
+++ b/src/components/Stars/Star.jsx
@@ -1,113 +1,159 @@
-import { useRef, useEffect } from "react";
-import { Canvas, useThree, useFrame } from "@react-three/fiber";
-import { SpriteMaterial, MathUtils, Vector3 } from "three";
+import { useRef, useEffect, useMemo, memo } from "react";
+import { useThree, useFrame } from "@react-three/fiber";
+import {
+ SpriteMaterial,
+ Vector3,
+ SphereGeometry,
+ Color,
+ BufferAttribute,
+} from "three";
import FakeGlowMaterial from "../../utils/FakeGlowMaterial";
import { useStore } from "../../store";
import {
declinationToRadians,
rightAscensionToRadians,
sphericalToCartesian,
- convertMagnitude,
} from "../../utils/celestial-functions";
import HoverObj from "../HoverObj/HoverObj";
import createCircleTexture from "../../utils/createCircleTexture";
import colorTemperature2rgb from "../../utils/colorTempToRGB";
-import NameLabel from "../Labels/NameLabel";
+import NameLabel from "../Labels/NameLabelBillboard";
+import { pointShaderMaterial } from "./starShaders";
+
+const worldPositionVec = new Vector3();
+const sharedSphereGeometry = new SphereGeometry(1, 32, 32);
-export default function Star({ sData }) {
- const { camera, invalidate } = useThree();
+const Star = memo(function Star({ sData }) {
+ const { invalidate } = useThree();
const starDistanceModifier = useStore((s) => s.starDistanceModifier);
const officialStarDistances = useStore((s) => s.officialStarDistances);
const hScale = useStore((s) => s.hScale);
const starScale = useStore((s) => s.starScale);
- // const s = settings.find((obj) => obj.name === name);
+ const selectedStarHR = useStore((s) => s.selectedStarHR);
+ const setSelectedStarPosition = useStore((s) => s.setSelectedStarPosition);
const s = sData;
- // console.log(s)
-
- const color = colorTemperature2rgb(s.colorTemp);
-
const meshRef = useRef();
const groupRef = useRef();
- const minScreenSize = 0.1;
-
- const prevCameraPos = useRef(new Vector3());
- const prevFov = useRef(null);
+ const pointRef = useRef();
- const updateScale = (camera) => {
- if (!meshRef.current) return;
- const distance = camera.position.distanceTo(meshRef.current.position);
- const fov = MathUtils.degToRad(camera.fov);
- const apparentSize = (2 * Math.tan(fov / 2) * 1) / distance;
+ const color = useMemo(() => {
+ if (s.overrideColor) return s.overrideColor;
+ return colorTemperature2rgb(s.colorTemp);
+ }, [s.colorTemp, s.overrideColor]);
- if (distance > 0.1) {
- if (apparentSize < minScreenSize) {
- const scale = (minScreenSize / apparentSize) * 1;
- meshRef.current.scale.set(scale, scale, scale);
+ // Mount the position, color, and size directly to the new point geometry
+ useEffect(() => {
+ if (pointRef.current) {
+ const c = new Color(color);
+ const geo = pointRef.current.geometry;
+
+ geo.setAttribute(
+ "position",
+ new BufferAttribute(new Float32Array([0, 0, 0]), 3)
+ );
+ geo.setAttribute(
+ "color",
+ new BufferAttribute(new Float32Array([c.r, c.g, c.b]), 3)
+ );
+
+ let starsize;
+ if (s.magnitude < 1) {
+ starsize = 1.2;
+ } else if (s.magnitude >= 1 && s.magnitude < 3) {
+ starsize = 0.6;
+ } else if (s.magnitude >= 3 && s.magnitude < 5) {
+ starsize = 0.4;
} else {
- meshRef.current.scale.set(1, 1, 1);
+ starsize = 0.2;
}
+
+ const visualSize = starsize * starScale * 10;
+ geo.setAttribute(
+ "size",
+ new BufferAttribute(new Float32Array([visualSize]), 1)
+ );
}
- };
+ }, [color, s.magnitude, starScale]);
useEffect(() => {
- if (meshRef.current) {
- const raRad = rightAscensionToRadians(s.ra); // Convert RA to radians
- const decRad = declinationToRadians(s.dec); // Convert Dec to radians
+ if (meshRef.current && !s.isTargetClone) {
+ const raRad = rightAscensionToRadians(s.ra);
+ const decRad = declinationToRadians(s.dec);
let dist;
if (!officialStarDistances) {
dist = (20000 * hScale) / 100;
} else {
- //Convert light year distance to world units (1Ly = 63241 AU, 1 AU = 100 world units)
const worldDist = s.distLy * 63241 * 100;
dist =
- worldDist / (starDistanceModifier >= 1 ? starDistanceModifier : 1); // Distance
+ worldDist / (starDistanceModifier >= 1 ? starDistanceModifier : 1);
}
- // Convert spherical coordinates (RA, Dec, Dist) to Cartesian coordinates (x, y, z)
const { x, y, z } = sphericalToCartesian(raRad, decRad, dist);
- // Set the position of the star
groupRef.current.position.set(x, y, z);
- // updateScale(camera);
- invalidate();
}
- }, [s, starDistanceModifier, officialStarDistances, hScale]);
-
- const circleTexture = createCircleTexture(color);
- const spriteMaterial = new SpriteMaterial({
- map: circleTexture,
- transparent: true,
- opacity: 1,
- alphaTest: 0.5,
- sizeAttenuation: false,
- // depthTest: false,
+ }, [s.ra, s.dec, s.distLy, starDistanceModifier, officialStarDistances, hScale, invalidate, s.isTargetClone]);
+
+ useFrame(() => {
+ if (s.isTargetClone) return;
+
+ const myID = s.HR ? String(s.HR) : `Special:${s.name}`;
+
+ if (selectedStarHR === myID && groupRef.current) {
+ groupRef.current.getWorldPosition(worldPositionVec);
+ setSelectedStarPosition(worldPositionVec);
+ }
});
- let starsize;
- if (s.magnitude < 1) {
- starsize = 1.2;
- } else if (s.magnitude > 1 && s.magnitude < 3) {
- starsize = 0.6;
- } else if (s.magnitude > 3 && s.magnitude < 5) {
- starsize = 0.4;
- } else {
- starsize = 0.2;
- }
- const size = (starsize / 500) * starScale;
+ const spriteMaterial = useMemo(() => {
+ const circleTexture = createCircleTexture(color);
+ return new SpriteMaterial({
+ map: circleTexture,
+ transparent: true,
+ opacity: 1,
+ alphaTest: 0.5,
+ sizeAttenuation: false,
+ });
+ }, [color]);
+
+ const size = useMemo(() => {
+ let starsize;
+ if (s.magnitude < 1) {
+ starsize = 1.2;
+ } else if (s.magnitude >= 1 && s.magnitude < 3) {
+ starsize = 0.6;
+ } else if (s.magnitude >= 3 && s.magnitude < 5) {
+ starsize = 0.4;
+ } else {
+ starsize = 0.2;
+ }
+ return (starsize / 500) * starScale;
+ }, [s.magnitude, starScale]);
- if (s.BSCStar) {
+ if (s.BSCStar && !s.isTargetClone) {
return null;
}
+ // Globally suppress duplicate labels: If the star is selected in Search, HighlightSelectedStar assumes responsibility
+ const myID = s.HR ? String(s.HR) : `Special:${s.name}`;
+ const isCurrentlySearched =
+ selectedStarHR === myID ||
+ (s.isTargetClone && selectedStarHR === String(s.HR));
+ const showNameLabel = s.visible && !isCurrentlySearched;
+
return (
- {s.visible && }
-
-
-
+ {showNameLabel && }
+
+
+
+
+
+
+
- {s.visible && }
+ {s.visible && !s.isTargetClone && }
);
-}
+});
+
+export default Star;
diff --git a/src/components/Stars/Stars.jsx b/src/components/Stars/Stars.jsx
index 3f63836..690bc0d 100644
--- a/src/components/Stars/Stars.jsx
+++ b/src/components/Stars/Stars.jsx
@@ -2,7 +2,7 @@
import { useRef, useEffect } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { Vector3, Quaternion } from "three";
-import { usePlotStore, useStarStore } from "../../store";
+import { usePlotStore, useStarStore, useStore } from "../../store"; // Import useStore
import Star from "./Star";
import { dateTimeToPos } from "../../utils/time-date-functions";
import { movePlotModel } from "../../utils/plotModelFunctions";
@@ -11,12 +11,16 @@ const Stars = () => {
const plotObjects = usePlotStore((s) => s.plotObjects);
const starSettings = useStarStore((s) => s.settings);
+ // FIX: Subscribe to the global BSCStars flag
+ const showStars = useStore((s) => s.BSCStars);
+
const starGroupRef = useRef();
const worldPosition = new Vector3();
const worldQuaternion = new Quaternion();
useEffect(() => {
+ // Only update position if the group exists (which depends on showStars)
if (plotObjects.length > 0 && starGroupRef.current) {
const epochJ2000Pos = dateTimeToPos("2000-01-01", "12:00:00");
//We move the plot model to Epoch J2000 and copy Earths position and tilt
@@ -28,7 +32,10 @@ const Stars = () => {
starGroupRef.current.position.copy(worldPosition);
starGroupRef.current.quaternion.copy(worldQuaternion);
}
- }, [plotObjects]);
+ }, [plotObjects, showStars]); // Added showStars to dependency (optional but good practice)
+
+ // FIX: If the toggle is off, don't render any stars from this group
+ if (!showStars) return null;
return (
diff --git a/src/components/Stars/starShaders.js b/src/components/Stars/starShaders.js
index 71dea76..7ce9810 100644
--- a/src/components/Stars/starShaders.js
+++ b/src/components/Stars/starShaders.js
@@ -1,37 +1,45 @@
// src/components/Stars/starShaders.js
import * as THREE from "three";
-/**
- * Creates a circular texture for star rendering
- * @returns {THREE.Texture} A white circle texture
- */
-function createCircleTexture() {
+function createStarTexture(soft = false) {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
+ const center = size / 2;
+ const radius = size / 2 - 2;
+
context.beginPath();
- context.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
- context.fillStyle = "white";
+ context.arc(center, center, radius, 0, Math.PI * 2);
+
+ if (soft) {
+ const gradient = context.createRadialGradient(
+ center, center, 0, center, center, radius
+ );
+ gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
+ gradient.addColorStop(0.4, "rgba(255, 255, 255, 1)");
+ gradient.addColorStop(0.7, "rgba(255, 255, 255, 0.6)");
+ gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
+ context.fillStyle = gradient;
+ } else {
+ context.fillStyle = "white";
+ }
+
context.fill();
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
return texture;
}
-// Create texture once at module load time
-const circleTexture = createCircleTexture();
+// Only the visual texture is needed now
+const visualTexture = createStarTexture(true);
-/**
- * Shader material configuration for visible stars
- * Renders stars with color and opacity
- */
export const pointShaderMaterial = {
uniforms: {
- pointTexture: { value: circleTexture },
+ pointTexture: { value: visualTexture },
opacity: { value: 1.0 },
- alphaTest: { value: 0.1 },
+ alphaTest: { value: 0.01 },
},
vertexShader: `
attribute float size;
@@ -56,37 +64,4 @@ export const pointShaderMaterial = {
`,
vertexColors: true,
transparent: true,
-};
-
-/**
- * Shader material configuration for GPU-based star picking
- * Renders stars with unique colors for pixel-perfect picking
- */
-export const pickingShaderMaterial = {
- uniforms: {
- pointTexture: { value: circleTexture },
- alphaTest: { value: 0.1 },
- },
- vertexShader: `
- attribute float size;
- varying vec3 vColor;
- void main() {
- vColor = color;
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
- gl_PointSize = size;
- gl_Position = projectionMatrix * mvPosition;
- }
- `,
- fragmentShader: `
- uniform sampler2D pointTexture;
- uniform float alphaTest;
- varying vec3 vColor;
- void main() {
- vec4 texColor = texture2D(pointTexture, gl_PointCoord);
- if (texColor.a < alphaTest) discard;
- gl_FragColor = vec4(vColor, texColor.a);
- }
- `,
- vertexColors: true,
- transparent: false,
-};
+};
\ No newline at end of file
diff --git a/src/components/Stars/useBSCStarData.jsx b/src/components/Stars/useBSCStarData.jsx
index d551c91..7b25634 100644
--- a/src/components/Stars/useBSCStarData.jsx
+++ b/src/components/Stars/useBSCStarData.jsx
@@ -1,3 +1,4 @@
+// src/components/Stars/useBSCStarData.jsx
import { useMemo } from "react";
import { useStore } from "../../store";
import bscSettings from "../../settings/BSC.json";
@@ -8,129 +9,96 @@ import {
} from "../../utils/celestial-functions";
import colorTemperature2rgb from "../../utils/colorTempToRGB";
-// 1. Accept an optional argument (default is false so other components work as normal)
export const useBSCStarData = (forceProjected = false) => {
- // 2. Get the real setting from the store
const officialStarDistancesSetting = useStore((s) => s.officialStarDistances);
-
- // 3. Determine the effective value to use for calculation
- // If forceProjected is true, we treat officialStarDistances as false (Sphere view)
- const officialStarDistances = forceProjected ? false : officialStarDistancesSetting;
+ const officialStarDistances = forceProjected
+ ? false
+ : officialStarDistancesSetting;
const starDistanceModifier = useStore((s) => s.starDistanceModifier);
const hScale = useStore((s) => s.hScale);
const starScale = useStore((s) => s.starScale);
- const starPickingSensitivity = useStore((s) => s.starPickingSensitivity);
-
- const { positions, colors, sizes, pickingSizes, starData, pickingColors, colorMap } =
- useMemo(() => {
- const positions = [];
- const colors = [];
- const pickingColors = [];
- const sizes = [];
- const pickingSizes = [];
- const starData = [];
- const colorMap = new Map();
-
- bscSettings.forEach((s, index) => {
- const magnitude = parseFloat(s.V);
- const colorTemp = parseFloat(s.K) || 5778;
-
- const raRad = rightAscensionToRadians(s.RA);
- const decRad = declinationToRadians(s.Dec);
-
- const distLy = parseFloat(s.P) * 3.26156378;
- let dist;
-
- // 4. This logic now uses our local 'officialStarDistances' variable
- if (!officialStarDistances) {
- dist = (20000 * hScale) / 100;
- } else {
- const worldDist = distLy * 63241 * 100;
- dist =
- worldDist / (starDistanceModifier >= 1 ? starDistanceModifier : 1);
- }
-
- const { x, y, z } = sphericalToCartesian(raRad, decRad, dist);
- positions.push(x, y, z);
-
- const { red, green, blue } = colorTemperature2rgb(colorTemp, true);
- colors.push(red, green, blue);
-
- const colorIndex = index + 1;
- const r = (colorIndex & 0xff) / 255;
- const g = ((colorIndex >> 8) & 0xff) / 255;
- const b = ((colorIndex >> 16) & 0xff) / 255;
-
- pickingColors.push(r, g, b);
-
- const rInt = Math.round(r * 255);
- const gInt = Math.round(g * 255);
- const bInt = Math.round(b * 255);
- const hexColor = (rInt << 16) | (gInt << 8) | bInt;
-
- colorMap.set(hexColor, index);
-
- let starsize;
- if (magnitude < 1) {
- starsize = 1.2;
- } else if (magnitude > 1 && magnitude < 3) {
- starsize = 0.6;
- } else if (magnitude > 3 && magnitude < 5) {
- starsize = 0.4;
- } else {
- starsize = 0.2;
- }
-
- const visualSize = starsize * starScale * 10;
-
- let pickingSize;
- if (magnitude >= 3) {
- pickingSize = visualSize * starPickingSensitivity;
- } else {
- pickingSize = visualSize;
- }
-
- sizes.push(visualSize);
- pickingSizes.push(pickingSize);
-
- starData.push({
- name: (() => {
- if (s.N && s.HIP) {
- return `${s.N} / HIP ${s.HIP}`;
- } else if (s.HIP) {
- return `HIP ${s.HIP}`;
- } else if (s.HR) {
- return `HR ${s.HR}`;
- }
- return "Unknown";
- })(),
- HR: s.HR,
- magnitude: isNaN(magnitude) ? 5 : magnitude,
- colorTemp,
- ra: s.RA,
- dec: s.Dec,
- distLy,
- index: index,
- });
- });
- return {
- positions: new Float32Array(positions),
- colors: new Float32Array(colors),
- pickingColors: new Float32Array(pickingColors),
- sizes: new Float32Array(sizes),
- pickingSizes: new Float32Array(pickingSizes),
- starData,
- colorMap,
- };
- }, [
- officialStarDistances, // This dependency now tracks our local overridden variable
- hScale,
- starDistanceModifier,
- starScale,
- starPickingSensitivity,
- ]);
-
- return { positions, colors, sizes, pickingSizes, starData, pickingColors, colorMap };
-};
\ No newline at end of file
+ // Notice: Removed starPickingSensitivity
+
+ const { positions, colors, sizes, starData } = useMemo(() => {
+ const positions = [];
+ const colors = [];
+ const sizes = [];
+ const starData = [];
+
+ bscSettings.forEach((s, index) => {
+ const magnitude = parseFloat(s.V);
+ const colorTemp = parseFloat(s.K) || 5778;
+
+ const raRad = rightAscensionToRadians(s.RA);
+ const decRad = declinationToRadians(s.Dec);
+
+ const distLy = parseFloat(s.P) * 3.26156378;
+ let dist;
+
+ if (!officialStarDistances) {
+ dist = (20000 * hScale) / 100;
+ } else {
+ const worldDist = distLy * 63241 * 100;
+ dist =
+ worldDist / (starDistanceModifier >= 1 ? starDistanceModifier : 1);
+ }
+
+ const { x, y, z } = sphericalToCartesian(raRad, decRad, dist);
+ positions.push(x, y, z);
+
+ const { red, green, blue } = colorTemperature2rgb(colorTemp, true);
+ colors.push(red, green, blue);
+
+ // --- Removed all pickingColor and colorMap bitwise logic ---
+
+ let starsize;
+ if (magnitude < 1) {
+ starsize = 1.2;
+ } else if (magnitude >= 1 && magnitude < 3) {
+ // FIX: added '='
+ starsize = 0.6;
+ } else if (magnitude >= 3 && magnitude < 5) {
+ // FIX: added '='
+ starsize = 0.4;
+ } else {
+ starsize = 0.2;
+ }
+
+ const visualSize = starsize * starScale * 10;
+ sizes.push(visualSize);
+
+ // --- Removed pickingSizes logic ---
+
+ starData.push({
+ name: (() => {
+ if (s.N && s.HIP) {
+ return `${s.N} / HIP ${s.HIP}`;
+ } else if (s.HIP) {
+ return `HIP ${s.HIP}`;
+ } else if (s.HR) {
+ return `HR ${s.HR}`;
+ }
+ return "Unknown";
+ })(),
+ HR: s.HR,
+ magnitude: isNaN(magnitude) ? 5 : magnitude,
+ colorTemp,
+ ra: s.RA,
+ dec: s.Dec,
+ distLy,
+ index: index,
+ });
+ });
+
+ return {
+ positions: new Float32Array(positions),
+ colors: new Float32Array(colors),
+ sizes: new Float32Array(sizes),
+ starData,
+ };
+ }, [officialStarDistances, hScale, starDistanceModifier, starScale]);
+
+ return { positions, colors, sizes, starData };
+};
diff --git a/src/components/Trace/Trace.jsx b/src/components/Trace/Trace.jsx
index f071606..f82c9d6 100644
--- a/src/components/Trace/Trace.jsx
+++ b/src/components/Trace/Trace.jsx
@@ -1,4 +1,5 @@
-import { useRef } from "react";
+import { useRef, useEffect } from "react";
+import { Vector3 } from "three";
import useFrameInterval from "../../utils/useFrameInterval";
import {
useStore,
@@ -6,14 +7,14 @@ import {
useTraceStore,
useSettingsStore,
} from "../../store";
-import { Vector3 } from "three";
import { getSpeedFact } from "../../utils/time-date-functions.js";
import { movePlotModel } from "../../utils/plotModelFunctions";
import TraceLine from "./TraceLine";
+const objectPos = new Vector3();
+
const Trace = ({ name }) => {
const plotObjects = usePlotStore((s) => s.plotObjects);
- const plotPosRef = useRef(0);
const posRef = useStore((s) => s.posRef);
const {
trace,
@@ -25,53 +26,80 @@ const Trace = ({ name }) => {
traceStartPos,
setTraceStart,
} = useTraceStore();
+
const getSetting = useSettingsStore((s) => s.getSetting);
const s = getSetting(name);
- //length should be multible by three
+
const traceLength =
Math.round((s.traceSettings.length * lengthMultiplier) / 3) * 3;
const traceStep =
s.traceSettings.step *
getSpeedFact(s.traceSettings.stepFact) *
stepMultiplier;
+
+ const plotPosRef = useRef(traceStartPos);
const pointsArrRef = useRef([]);
- const tracedObj = plotObjects.find((p) => p.name === name);
- const objectPos = new Vector3();
- plotPosRef.current = traceStartPos;
- pointsArrRef.current = [];
+ useEffect(() => {
+ plotPosRef.current = traceStartPos;
+ pointsArrRef.current = [];
+ }, [traceStartPos, trace, traceStep]);
useFrameInterval(() => {
if (!trace) return;
- // Check and adjust plotPos if the pos is out of bounds
if (plotPosRef.current < posRef.current - traceLength * traceStep) {
plotPosRef.current = posRef.current - traceLength * traceStep;
pointsArrRef.current = [];
}
+
if (plotPosRef.current > posRef.current + traceLength * traceStep) {
plotPosRef.current = posRef.current + traceLength * traceStep;
pointsArrRef.current = [];
- //If we move backwards out of bounds we need to update trace start!
setTraceStart(posRef.current);
}
- while (plotPosRef.current > posRef.current) {
- plotPosRef.current = plotPosRef.current - traceStep;
+ // TIME-SLICING: Limit calculations to prevent frame drops
+ // 50 is a safe baseline. Increase if it grows too slowly, decrease if it still stutters.
+ const MAX_STEPS_PER_FRAME = 50;
+ let stepsThisFrame = 0;
- pointsArrRef.current.splice(pointsArrRef.current.length - 3, 3);
+ // Rewinding backwards
+ while (
+ plotPosRef.current > posRef.current &&
+ stepsThisFrame < MAX_STEPS_PER_FRAME
+ ) {
+ plotPosRef.current -= traceStep;
+ if (pointsArrRef.current.length >= 3) {
+ pointsArrRef.current.length -= 3;
+ }
+ stepsThisFrame++;
}
- while (plotPosRef.current < posRef.current - traceStep) {
- plotPosRef.current = plotPosRef.current + traceStep;
+ // Tracing forwards (The heavy calculation)
+ while (
+ plotPosRef.current < posRef.current - traceStep &&
+ stepsThisFrame < MAX_STEPS_PER_FRAME
+ ) {
+ plotPosRef.current += traceStep;
+
+ // This is the expensive call being throttled
movePlotModel(plotObjects, plotPosRef.current);
- tracedObj.pivotRef.current.getWorldPosition(objectPos);
- if (pointsArrRef.current.length + 3 > traceLength * 3) {
+
+ const tracedObj = plotObjects.find((p) => p.name === name);
+ if (tracedObj && tracedObj.pivotRef.current) {
+ tracedObj.pivotRef.current.getWorldPosition(objectPos);
+ pointsArrRef.current.push(objectPos.x, objectPos.y, objectPos.z);
+ }
+
+ if (pointsArrRef.current.length > traceLength * 3) {
pointsArrRef.current.splice(0, 3);
}
- pointsArrRef.current.push(objectPos.x, objectPos.y, objectPos.z);
+
+ stepsThisFrame++;
}
}, interval);
+
return (
{
dots={dotted}
lineWidth={lineWidth}
interval={interval}
+ raycast={() => null}
/>
);
};
diff --git a/src/components/Trace/TraceController.jsx b/src/components/Trace/TraceController.jsx
index 9d7514d..a7ecfb0 100644
--- a/src/components/Trace/TraceController.jsx
+++ b/src/components/Trace/TraceController.jsx
@@ -1,46 +1,48 @@
import { useControls } from "leva";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { useStore, useSettingsStore, useTraceStore } from "../../store";
import Trace from "./Trace";
+
const TraceController = () => {
const { settings } = useSettingsStore();
const { trace, setTraceStart } = useTraceStore();
const posRef = useStore((s) => s.posRef);
- const traceablePlanets = settings
- .filter((item) => item.traceable)
- .map((item) => item.name);
-
- //Create a leva checkbox object for each traceable planet
- const checkboxes = {};
- traceablePlanets.forEach((item) => {
- checkboxes[item] = false;
- });
- //Mars should be on by default
- checkboxes.Mars = true;
-
- const tracedPlanets = useControls("Trace", {
- "Planets:": { value: "", editable: false },
- ...checkboxes,
- });
-
- //Filter out the planets that are checked
- const checkedPlanets = Object.keys(tracedPlanets)
- .filter((key) => tracedPlanets[key] === true)
- .map((key) => key);
-
- //If trace is turned on, update trace startPos
+ // OPTIMIZATION: Memoize the configuration object to prevent re-creation on every render
+ const planetsConfig = useMemo(() => {
+ const checkboxes = { "Planets:": { value: "", editable: false } };
+
+ settings
+ .filter((item) => item.traceable)
+ .forEach((item) => {
+ checkboxes[item.name] = item.name === "Mars"; // Default Mars to true
+ });
+
+ return checkboxes;
+ }, [settings]);
+
+ const tracedPlanets = useControls("Trace", planetsConfig);
+
+ // Filter out the planets that are checked
+ // OPTIMIZATION: Memoize this list so we don't map/filter on every render
+ const checkedPlanets = useMemo(
+ () =>
+ Object.keys(tracedPlanets).filter(
+ (key) => tracedPlanets[key] === true && key !== "Planets:"
+ ),
+ [tracedPlanets]
+ );
+
useEffect(() => {
if (trace) {
setTraceStart(posRef.current);
}
- }, [trace]);
+ }, [trace, setTraceStart]); // Added dependency
- // Trace checked planets
return (
<>
- {checkedPlanets.map((item, index) => (
-
+ {checkedPlanets.map((item) => (
+
))}
>
);
diff --git a/src/components/Trace/TraceLine.jsx b/src/components/Trace/TraceLine.jsx
index 828c3d8..5f58105 100644
--- a/src/components/Trace/TraceLine.jsx
+++ b/src/components/Trace/TraceLine.jsx
@@ -1,5 +1,6 @@
-import { useEffect, useRef } from "react";
+import { useRef, useMemo } from "react";
import { Line } from "@react-three/drei";
+import { Color } from "three";
import useFrameInterval from "../../utils/useFrameInterval";
const TraceLine = ({
@@ -10,39 +11,120 @@ const TraceLine = ({
lineWidth,
interval,
}) => {
- // Note: Animating lines in Three.js is tricky. The array that geometry.setPositions receive
- // must be a Float32 array of the same length and be filled [x,y,z,x,y,z...] .
- // So to acheive a line that becomes progessively longer we fill the array with zeroes
- // and then increase geometry.instanceCount that sets how many points in the array that is
- // actually drawn.
-
- const float32arr = new Float32Array(traceLength * 3); //xyz for each point
- float32arr.fill(0);
const line2Ref = useRef(null);
- // const { invalidate } = useThree();
- //If lineWidth is negative the line becomes dotted
+ const baseColor = useMemo(() => new Color(color), [color]);
+
+ // Provide initial full-length arrays so Drei pre-allocates the max WebGL buffer ONCE
+ const initialPoints = useMemo(
+ () => new Array(traceLength * 3).fill(0),
+ [traceLength]
+ );
+ const initialColors = useMemo(() => {
+ const arr = new Array(traceLength * 3);
+ for (let i = 0; i < traceLength; i++) {
+ arr[i * 3] = baseColor.r;
+ arr[i * 3 + 1] = baseColor.g;
+ arr[i * 3 + 2] = baseColor.b;
+ }
+ return arr;
+ }, [traceLength, baseColor]);
+
const width = dots ? -lineWidth : lineWidth;
useFrameInterval(
() => {
- float32arr.set(pointsArrRef.current);
- line2Ref.current.geometry.setPositions(float32arr);
- line2Ref.current.geometry.instanceCount =
- (pointsArrRef.current.length - 1) / 3;
+ if (!pointsArrRef.current || !line2Ref.current) return;
+
+ const pts = pointsArrRef.current;
+ const numPoints = Math.floor(pts.length / 3);
+ const segmentCount = Math.max(0, numPoints - 1);
+
+ if (segmentCount === 0) {
+ line2Ref.current.geometry.instanceCount = 0;
+ return;
+ }
+
+ const geometry = line2Ref.current.geometry;
+
+ // Drei's LineGeometry uses InstancedInterleavedBuffers.
+ // instanceStart and instanceEnd share the SAME buffer.
+ const iStart = geometry.attributes.instanceStart;
+ const cStart = geometry.attributes.instanceColorStart;
+
+ if (!iStart || !cStart) return;
+
+ // Access the underlying shared buffer arrays
+ const positionBuffer = iStart.data;
+ const colorBuffer = cStart.data;
+
+ const posArr = positionBuffer.array;
+ const colArr = colorBuffer.array;
+
+ const fadeLen = 20;
+ const effectiveFade = Math.max(
+ 1,
+ Math.min(fadeLen, Math.floor(numPoints / 2))
+ );
+
+ // Directly write into the interleaved buffer (6 floats per segment: Start XYZ, End XYZ)
+ for (let i = 0; i < segmentCount; i++) {
+ const bufferIdx = i * 6; // Index in the interleaved buffer
+ const ptIdx = i * 3; // Index in our raw points array
+
+ // --- 1. Positions (Start XYZ, End XYZ) ---
+ posArr[bufferIdx] = pts[ptIdx];
+ posArr[bufferIdx + 1] = pts[ptIdx + 1];
+ posArr[bufferIdx + 2] = pts[ptIdx + 2];
+
+ posArr[bufferIdx + 3] = pts[ptIdx + 3];
+ posArr[bufferIdx + 4] = pts[ptIdx + 4];
+ posArr[bufferIdx + 5] = pts[ptIdx + 5];
+
+ // --- 2. Colors and Fading ---
+ let alphaStart = 1.0;
+ let alphaEnd = 1.0;
+
+ // Tail Fade (Oldest points)
+ if (i < effectiveFade) {
+ alphaStart = i / effectiveFade;
+ alphaEnd = (i + 1) / effectiveFade;
+ }
+ // Head Fade (Newest points)
+ else if (i >= numPoints - effectiveFade - 1) {
+ alphaStart = (numPoints - i) / effectiveFade;
+ alphaEnd = (numPoints - (i + 1)) / effectiveFade;
+ }
+
+ colArr[bufferIdx] = baseColor.r * alphaStart;
+ colArr[bufferIdx + 1] = baseColor.g * alphaStart;
+ colArr[bufferIdx + 2] = baseColor.b * alphaStart;
+
+ colArr[bufferIdx + 3] = baseColor.r * alphaEnd;
+ colArr[bufferIdx + 4] = baseColor.g * alphaEnd;
+ colArr[bufferIdx + 5] = baseColor.b * alphaEnd;
+ }
+
+ // --- 3. Flag the buffers, not the attributes, for GPU upload ---
+ positionBuffer.needsUpdate = true;
+ colorBuffer.needsUpdate = true;
+
+ // Ensure Three.js only draws the active segments
+ geometry.instanceCount = segmentCount;
},
interval,
true
);
- // invalidate();
return (
+ color="white"
+ frustumCulled={false}
+ />
);
};
diff --git a/src/components/UIZoom.jsx b/src/components/UIZoom.jsx
index 35e078c..1341f08 100644
--- a/src/components/UIZoom.jsx
+++ b/src/components/UIZoom.jsx
@@ -8,14 +8,13 @@ const UIZoom = () => {
// // const {zoomLevel, zoomIn, zoomOut} = useStore();
const { zoomLevel, zoomIn, zoomOut } = useStore();
-
useEffect(() => {
changeZoom(zoomLevel);
}, [zoomLevel]);
return (
<>
- {
title={`Zoom: ${zoomLevel}%`}
>
- {" "}
+ {" "} */}
>
);
};
diff --git a/src/components/UserInterface.jsx b/src/components/UserInterface.jsx
index aa9aad3..2dca841 100644
--- a/src/components/UserInterface.jsx
+++ b/src/components/UserInterface.jsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
import {
FaPlay,
FaPause,
@@ -6,6 +7,7 @@ import {
FaStepForward,
FaBars,
FaTimes,
+ FaQuestionCircle,
FaShareAlt,
FaExternalLinkAlt,
FaGithub,
@@ -34,6 +36,8 @@ import {
} from "../utils/time-date-functions";
import UIZoom from "./UIZoom";
+import TychosLogoIcon from "../utils/TychosLogoIcon";
+
const UserInterface = () => {
const {
run,
@@ -59,6 +63,10 @@ const UserInterface = () => {
const julianRef = useRef();
const intervalRef = useRef();
+ // Refs for stepping logic
+ const steppingInterval = useRef(null);
+ const steppingTimeout = useRef(null);
+
useEffect(() => {
dateRef.current.value = posToDate(posRef.current);
timeRef.current.value = posToTime(posRef.current);
@@ -81,6 +89,66 @@ const UserInterface = () => {
}
}, [run]);
+ // Cleanup stepping timers on unmount
+ useEffect(() => {
+ return () => stopStepping();
+ }, []);
+
+ const performStep = (direction) => {
+ // direction: 1 for forward, -1 for backward
+ if (speedFact === sYear) {
+ posRef.current =
+ dateToDays(
+ addYears(dateRef.current.value, speedMultiplier * direction)
+ ) *
+ sDay +
+ timeToPos(timeRef.current.value);
+ } else if (speedFact === sMonth) {
+ posRef.current =
+ dateToDays(
+ addMonths(dateRef.current.value, speedMultiplier * direction)
+ ) *
+ sDay +
+ timeToPos(timeRef.current.value);
+ } else {
+ posRef.current += speedFact * speedMultiplier * direction;
+ }
+
+ dateRef.current.value = posToDate(posRef.current);
+ timeRef.current.value = posToTime(posRef.current);
+ julianRef.current.value = posToJulianDay(posRef.current);
+ updateAC();
+ };
+
+ const startStepping = (direction) => {
+ // 1. Perform immediate step for responsiveness
+ performStep(direction);
+
+ // 2. Clear any existing timers to be safe
+ if (steppingTimeout.current) clearTimeout(steppingTimeout.current);
+ if (steppingInterval.current) clearInterval(steppingInterval.current);
+
+ // 3. Set a timeout: wait 500ms before starting the continuous loop
+ steppingTimeout.current = setTimeout(() => {
+ steppingInterval.current = setInterval(() => {
+ performStep(direction);
+ }, 100); // Speed of continuous stepping
+ }, 500); // Delay before continuous stepping starts
+ };
+
+ const stopStepping = () => {
+ // Clear the timeout (if user released button before 500ms)
+ if (steppingTimeout.current) {
+ clearTimeout(steppingTimeout.current);
+ steppingTimeout.current = null;
+ }
+ // Clear the interval (if continuous stepping was running)
+ if (steppingInterval.current) {
+ clearInterval(steppingInterval.current);
+ steppingInterval.current = null;
+ }
+ };
+
function dateKeyDown(e) {
// Prevent planet camera from moving
e.stopPropagation();
@@ -186,7 +254,7 @@ const UserInterface = () => {
toggleShowMenu();
};
- return (
+ return createPortal(
<>
{
position: "fixed",
top: "14px",
right: "12px",
- zIndex: 20,
+ zIndex: 2147483647,
background: "#374151",
border: "none",
borderRadius: "6px",
@@ -205,16 +273,30 @@ const UserInterface = () => {
cursor: "pointer",
}}
>
-
+
-
The TYCHOSIUM
+
+ {" "}
+ {/* Bumped size to match the 2rem font */}
+ The Tychosium
+
setShowHelp(true)}
style={{ marginRight: "0.25rem", marginLeft: "0.5rem" }} // Add spacing
>
-
+
@@ -254,65 +336,27 @@ const UserInterface = () => {
>
Today
+
+ {/* BACKWARD BUTTON */}
{
- if (speedFact === sYear) {
- posRef.current =
- dateToDays(
- addYears(dateRef.current.value, -speedMultiplier)
- ) *
- sDay +
- timeToPos(timeRef.current.value);
- } else {
- if (speedFact === sMonth) {
- posRef.current =
- dateToDays(
- addMonths(dateRef.current.value, -speedMultiplier)
- ) *
- sDay +
- timeToPos(timeRef.current.value);
- } else {
- posRef.current -= speedFact * speedMultiplier;
- }
- }
-
- dateRef.current.value = posToDate(posRef.current);
- timeRef.current.value = posToTime(posRef.current);
- julianRef.current.value = posToJulianDay(posRef.current);
- updateAC();
- }}
+ onMouseDown={() => startStepping(-1)}
+ onMouseUp={stopStepping}
+ onMouseLeave={stopStepping}
>
+
{run ? : }
+
+ {/* FORWARD BUTTON */}
{
- if (speedFact === sYear) {
- posRef.current =
- dateToDays(addYears(dateRef.current.value, speedMultiplier)) *
- sDay +
- timeToPos(timeRef.current.value);
- } else {
- if (speedFact === sMonth) {
- posRef.current =
- dateToDays(
- addMonths(dateRef.current.value, speedMultiplier)
- ) *
- sDay +
- timeToPos(timeRef.current.value);
- } else {
- posRef.current += speedFact * speedMultiplier;
- }
- }
- dateRef.current.value = posToDate(posRef.current);
- timeRef.current.value = posToTime(posRef.current);
- julianRef.current.value = posToJulianDay(posRef.current);
- updateAC();
- }}
+ onMouseDown={() => startStepping(1)}
+ onMouseUp={stopStepping}
+ onMouseLeave={stopStepping}
>
@@ -357,12 +401,13 @@ const UserInterface = () => {
/>
- >
+ >,
+ document.body
);
};
diff --git a/src/index.css b/src/index.css
index b5b6cd7..acbbd11 100644
--- a/src/index.css
+++ b/src/index.css
@@ -48,6 +48,9 @@ body {
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
+ width: 250px; /* Reduced from 350px */
+ max-width: 280px; /* Reduced from 450px */
+ min-width: 240px;
}
.menu[hidden] {
@@ -68,7 +71,8 @@ body {
font-family: Cambria, Georgia, serif;
font-weight: bold;
font-size: 2rem;
- white-space: nowrap;
+ text-align: center;
+ white-space: normal;
}
.menu-header-button {
@@ -84,6 +88,11 @@ body {
width: 100%;
}
+/* Ensure the Leva wrapper takes full width */
+.leva-container {
+ width: 100%;
+}
+
.menu-button {
display: flex;
align-items: center;
@@ -97,6 +106,7 @@ body {
cursor: pointer;
color: white;
transition: background-color 0.2s ease; /* Add hover effect */
+ border: none;
}
.menu-button:hover {
@@ -180,22 +190,38 @@ body {
/* Override Leva’s inline styles for its root element */
.positions-div > div {
position: fixed;
- top: 60px !important;
+ top: 110px;
+ left: 10px;
+ right: auto;
+ width: 300px; /* Kept at 300px */
+ opacity: 0.8;
+}
+.ephemerides-div > div {
+ position: fixed;
+ top: 170px !important;
left: 10px !important;
right: auto !important;
opacity: 0.8;
}
-/* Override Leva’s inline styles for its root element */
+
+.recorder-div > div {
+ position: fixed;
+ top: 230px !important; /* Stacks neatly below Ephemerides (170px) */
+ left: 10px !important;
+ right: auto !important;
+ width: 300px;
+ opacity: 0.8;
+}
+
.settings-div > div {
position: fixed;
top: 60px !important;
left: 10px !important;
right: auto !important;
height: auto;
- width: 350px;
+ width: 400px; /* Kept wider for settings readability */
opacity: 0.8;
}
-
.plancam-div > div {
position: fixed;
bottom: 10px !important;
@@ -203,20 +229,23 @@ body {
left: 10px !important;
right: auto !important;
opacity: 0.8;
- max-height: calc(100vh - 100px) !important; /* Don't exceed viewport */
+ width: 350px;
+ max-height: calc(100vh - 100px) !important;
overflow-y: auto !important;
+ z-index: 2147483647 !important;
}
.name-label {
color: white;
- font-size: 1rem;
+ font-size: 1.2rem;
font-family: Arial, Helvetica, sans-serif;
opacity: 0.8;
user-select: none;
- white-space: nowrap; /*Prevent text wrapping*/
+ white-space: nowrap; /* Prevent text wrapping */
pointer-events: none;
-}
-
-.starSearch-input::placeholder {
- color: #d1d5db; /* Brighter placeholder text color */
+
+ /* --- New Background Styles --- */
+ background-color: rgba(0, 0, 0, 0.3); /* Semi-transparent black */
+ padding: 0.2rem 0.4rem; /* Spacing around the text */
+ border-radius: 0.25rem; /* Slightly rounded corners */
}
diff --git a/src/settings/BSC.json b/src/settings/BSC.json
index d5ab549..7c86735 100644
--- a/src/settings/BSC.json
+++ b/src/settings/BSC.json
@@ -28521,22 +28521,9 @@
"V": "6.61",
"K": "10000"
},
- {
- "HR": "2890",
- "HIP": null,
- "RA": "07h 34m 36.0s",
- "Dec": "+31° 53′ 19″",
- "P": 15.599999999999998,
- "V": "2.88",
- "K": "9500",
- "B": "α",
- "N": "Castor",
- "C": "Gem",
- "F": "66"
- },
{
"HR": "2891",
- "HIP": null,
+ "HIP": 36850,
"RA": "07h 34m 36.0s",
"Dec": "+31° 53′ 18″",
"P": 15.595757953836557,
diff --git a/src/settings/celestial-settings.json b/src/settings/celestial-settings.json
index 67e12f4..c0f6527 100644
--- a/src/settings/celestial-settings.json
+++ b/src/settings/celestial-settings.json
@@ -5,6 +5,8 @@
"startPos": 0,
"speed": 0,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 0,
"orbitRadius": 0,
"orbitCentera": 0,
"orbitCenterb": 0,
@@ -18,9 +20,10 @@
"actualSize": 0.00426,
"startPos": 0,
"speed": -0.0002479160869310127,
- "rotationSpeed": 2301.1694948647196,
"tilt": -23.439062,
"tiltb": 0.26,
+ "rotationStart": 0,
+ "rotationSpeed": 2301.1694948647196,
"orbitRadius": 37.8453,
"orbitCentera": 0,
"orbitCenterb": 0,
@@ -61,6 +64,8 @@
"startPos": 261.2,
"speed": 83.28521,
"tilt": 0,
+ "rotationStart": 3.14159,
+ "rotationSpeed": 0,
"orbitRadius": 0.25505129081458283,
"orbitCentera": 0.020404103265166628,
"orbitCenterb": -0.02065915455598121,
@@ -87,9 +92,10 @@
"actualSize": 0.465,
"startPos": 0,
"speed": 6.283185307179586,
- "rotationSpeed": 83.995,
"tilt": 0,
"tiltb": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 90.5,
"orbitRadius": 100,
"orbitCentera": 1.2,
"orbitCenterb": -0.1,
@@ -130,6 +136,8 @@
"startPos": -180.8,
"speed": 26.08763045,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 0,
"orbitRadius": 38.710225,
"orbitCentera": 0.6,
"orbitCenterb": 3,
@@ -170,6 +178,8 @@
"startPos": -23.6,
"speed": 10.21331385,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 0,
"orbitRadius": 72.327789,
"orbitCentera": 0.6,
"orbitCenterb": -0.9,
@@ -209,7 +219,8 @@
"actualSize": 0.00227,
"startPos": 119.3,
"speed": -3.33985,
- "rotationSpeed": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 2301.169494864719,
"tilt": 0,
"orbitRadius": 152.677,
"orbitCentera": 0,
@@ -265,8 +276,9 @@
"actualSize": 0.0467,
"startPos": -34,
"speed": 0.52994136,
- "rotationSpeed": 0,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 5562.3218194,
"orbitRadius": 520.4,
"orbitCentera": -49,
"orbitCenterb": 3,
@@ -293,8 +305,9 @@
"actualSize": 0.0389,
"startPos": -123.8,
"speed": 0.21351984,
- "rotationSpeed": 0,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 5244.4748582,
"orbitRadius": 958.2,
"orbitCentera": 69,
"orbitCenterb": 40,
@@ -321,8 +334,9 @@
"actualSize": 0.0169,
"startPos": 371.8,
"speed": 0.07500314,
- "rotationSpeed": 0,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 3239.2344713,
"orbitRadius": 1920.13568,
"orbitCentera": 150,
"orbitCenterb": -65,
@@ -349,8 +363,9 @@
"actualSize": 0.0164,
"startPos": 329.3,
"speed": 0.03837314,
- "rotationSpeed": 0,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 3059.2770006,
"orbitRadius": 3004.72,
"orbitCentera": 0,
"orbitCenterb": 20,
@@ -358,6 +373,35 @@
"orbitTilta": -1.6,
"orbitTiltb": 1.15
},
+ {
+ "name": "Pluto deferent",
+ "size": 1,
+ "startPos": 0,
+ "speed": -6.283185307179586,
+ "tilt": 0,
+ "orbitRadius": 0,
+ "orbitCentera": 0,
+ "orbitCenterb": 0,
+ "orbitCenterc": 0,
+ "orbitTilta": 0,
+ "orbitTiltb": 0
+ },
+ {
+ "name": "Pluto",
+ "size": 0.9,
+ "actualSize": 0.00079,
+ "startPos": 0,
+ "speed": 0.02533,
+ "tilt": 122.5,
+ "rotationStart": 0,
+ "rotationSpeed": 1000,
+ "orbitRadius": 3948.2,
+ "orbitCentera": 980,
+ "orbitCenterb": -200,
+ "orbitCenterc": 0,
+ "orbitTilta": 15.0,
+ "orbitTiltb": 5.0
+ },
{
"name": "Halleys deferent",
"size": 1,
@@ -373,11 +417,13 @@
},
{
"name": "Halleys",
- "size": 2,
+ "size": 0.1,
"actualSize": 0.00000368,
"startPos": 76.33,
"speed": -0.0830100973,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 0,
"orbitRadius": 1674.5,
"orbitCentera": -1540,
"orbitCenterb": -233.5,
@@ -413,11 +459,13 @@
},
{
"name": "Eros",
- "size": 2,
+ "size": 0.1,
"actualSize": 0.00000563,
"startPos": 171.8,
"speed": 4.57668492,
"tilt": 0,
+ "rotationStart": 0,
+ "rotationSpeed": 0,
"orbitRadius": 145.79,
"orbitCentera": 5.2,
"orbitCenterb": -6,
diff --git a/src/settings/labeled-stars.json b/src/settings/labeled-stars.json
new file mode 100644
index 0000000..c6ea992
--- /dev/null
+++ b/src/settings/labeled-stars.json
@@ -0,0 +1,28 @@
+[
+ "Polaris",
+ "Sirius",
+ "Procyon",
+ "Deneb Algedi",
+ "Betelgeuse",
+ "Rigel",
+ "Canopus",
+ "Vega",
+ "Thuban",
+ "Capella",
+ "Altair",
+ "Aldebaran",
+ "Antares",
+ "Arcturus",
+ "Achernar",
+ "Polaris Australis",
+ "Hadar",
+ "Spica",
+ "Rigil Kentaurus",
+ "Acrux",
+ "Pollux",
+ "Formalhaut",
+ "Mimosa",
+ "Regulus",
+ "Adhara",
+ "Castor"
+]
diff --git a/src/settings/misc-settings.json b/src/settings/misc-settings.json
index 039ee95..8b4138f 100644
--- a/src/settings/misc-settings.json
+++ b/src/settings/misc-settings.json
@@ -22,7 +22,8 @@
"texture": "/textures/8k_earth_daymap.jpg",
"unicodeSymbol": "🜨",
"planetCamera": true,
- "description": "EARTH orbital Ø: 113 230 656 km (the “PVP” orbit), Orbital circumference: 355 724 597 km, Diameter at equator: 12756.3 km, Equatorial circumference: 40075 km"
+ "groundColor": "#000080",
+ "horizonColor": "#000080"
},
{
"name": "Moon deferent A",
@@ -40,11 +41,12 @@
"name": "Moon",
"color": "#8b8b8b",
"type": "planet",
- "rotationStart": 3,
"visible": true,
"texture": "/textures/planets/2k_moon.jpg",
"unicodeSymbol": "☽",
"planetCamera": true,
+ "groundColor": "#4A4A4A",
+ "horizonColor": "#8B8B8B",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
},
@@ -68,6 +70,8 @@
"glow": true,
"unicodeSymbol": "☉",
"planetCamera": true,
+ "groundColor": "#FFA500",
+ "horizonColor": "#FFD700",
"traceable": true,
"traceSettings": { "length": 5000, "step": 10, "stepFact": "sYear" }
},
@@ -90,8 +94,11 @@
"visible": true,
"arrows": true,
"texture": "/textures/planets/2k_mercury.jpg",
+ "textureTint": "#555555",
"unicodeSymbol": "☿",
"planetCamera": true,
+ "groundColor": "#696969",
+ "horizonColor": "#A9A9A9",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
},
@@ -116,6 +123,8 @@
"texture": "/textures/planets/2k_venus.jpg",
"unicodeSymbol": "♀",
"planetCamera": true,
+ "groundColor": "#B8860B",
+ "horizonColor": "#DAA520",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
},
@@ -141,6 +150,8 @@
"texture": "/textures/planets/2k_mars.jpg",
"unicodeSymbol": "♂",
"planetCamera": true,
+ "groundColor": "#B7410E",
+ "horizonColor": "#D2691E",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
},
@@ -249,6 +260,22 @@
"opacity": 0.2
}
},
+ {
+ "name": "Pluto deferent",
+ "type": "deferent",
+ "visible": false
+ },
+ {
+ "name": "Pluto",
+ "color": "#e3d2b4",
+ "type": "planet",
+ "visible": false,
+ "texture": "/textures/planets/2k_pluto.jpg",
+ "unicodeSymbol": "♇",
+ "planetCamera": true,
+ "traceable": true,
+ "traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
+ },
{
"name": "Halleys deferent",
"type": "deferent",
@@ -261,7 +288,7 @@
"visible": false,
"planet": true,
"texture": "",
- "unicodeSymbol": "",
+ "unicodeSymbol": "☄",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
},
@@ -281,7 +308,7 @@
"type": "planet",
"visible": false,
"texture": "",
- "unicodeSymbol": "",
+ "unicodeSymbol": "♂",
"traceable": true,
"traceSettings": { "length": 5000, "step": 1, "stepFact": "sDay" }
}
diff --git a/src/settings/star-settings.json b/src/settings/star-settings.json
index 55e11a3..fec15ef 100644
--- a/src/settings/star-settings.json
+++ b/src/settings/star-settings.json
@@ -18,8 +18,7 @@
"magnitude": 13.2,
"ra": "17h 57m 47.13s",
"dec": "+04° 41' 34.0″",
- "distLy": 5.96,
- "HR": "6870"
+ "distLy": 5.96
},
{
"name": "Kruger 60",
@@ -29,7 +28,6 @@
"magnitude": 11.5,
"ra": "22h 27m 59.00s",
"dec": "+57° 42' 03.2″",
- "distLy": 13.07,
- "HR": "8555"
+ "distLy": 13.07
}
]
diff --git a/src/store.js b/src/store.js
index 5268592..75d2a3c 100644
--- a/src/store.js
+++ b/src/store.js
@@ -20,12 +20,19 @@ export const useStore = create((set) => ({
setSearchStars: (v) => set({ searchStars: v }),
activeCamera: "orbit",
cameraTarget: "Earth",
- cameraUpdate: 0, // Add a trigger value
+ cameraUpdate: 0,
setCameraTarget: (v) =>
set((state) => ({
cameraTarget: v,
cameraUpdate: state.cameraUpdate + 1,
})),
+ searchTarget: null,
+ searchUpdate: 0,
+ setSearchTarget: (v) =>
+ set((state) => ({
+ searchTarget: v,
+ searchUpdate: state.searchUpdate + 1,
+ })),
cameraFollow: false,
setCameraFollow: (v) => set({ cameraFollow: v }),
@@ -52,11 +59,15 @@ export const useStore = create((set) => ({
showMenu: true,
toggleShowMenu: () => set((state) => ({ showMenu: !state.showMenu })),
- sunLight: 2,
+ sunLight: 1,
zodiac: false,
setZodiac: (v) => set({ zodiac: v }),
- zodiacSize: 100,
+
+ tropicalZodiac: false,
+ setTropicalZodiac: (v) => set({ tropicalZodiac: v }),
+
+ zodiacSize: 130,
setZodiacSize: (v) => set({ zodiacSize: v }),
polarLine: false,
@@ -81,7 +92,7 @@ export const useStore = create((set) => ({
officialStarDistances: true,
setOfficialStarDistances: (v) => set({ officialStarDistances: v }),
-
+
// Added Constellations State
showConstellations: false,
setShowConstellations: (v) => set({ showConstellations: v }),
@@ -96,7 +107,7 @@ export const useStore = create((set) => ({
BSCStars: true,
setBSCStars: (v) => set({ BSCStars: v }),
- starPickingSensitivity: 2.0, // 2x the visual size
+ // starPickingSensitivity: 2.0, // 2x the visual size
//Trigger update flags
resetClicked: false,
@@ -105,7 +116,7 @@ export const useStore = create((set) => ({
updAC: false, //When this value changes AnimationController rerenders
updateAC: () => set((state) => ({ updAC: !state.updAC })),
- zoomLevel: 80, // Initial zoom level
+ zoomLevel: 60, // Initial zoom level
setZoom: (level) => set({ zoomLevel: level }),
zoomIn: () =>
set((state) => ({
@@ -136,12 +147,19 @@ export const useStore = create((set) => ({
ephimerides: false,
setEphemerides: (v) => set({ ephimerides: v }),
+ plot: false,
+ setPlot: (v) => set({ plot: v }),
+
selectedStarHR: null,
setSelectedStarHR: (starHR) => set({ selectedStarHR: starHR }),
+
selectedStarPosition: null,
setSelectedStarPosition: (position) =>
set({ selectedStarPosition: position }),
+ selectedStarData: null,
+ setSelectedStarData: (data) => set({ selectedStarData: data }),
+
showHelp: false,
setShowHelp: (v) => set({ showHelp: v }),
@@ -159,6 +177,9 @@ export const useStore = create((set) => ({
cameraTransitioning: false,
setCameraTransitioning: (transitioning) =>
set({ cameraTransitioning: transitioning }),
+
+ showRecorder: false,
+ setShowRecorder: (v) => set({ showRecorder: v }),
}));
export const usePosStore = create((set) => ({
@@ -248,4 +269,4 @@ export const useStarStore = create((set, get) => ({
return { settings: newSettings };
});
},
-}));
\ No newline at end of file
+}));
diff --git a/src/utils/TychosLogoIcon.jsx b/src/utils/TychosLogoIcon.jsx
new file mode 100644
index 0000000..6f623e6
--- /dev/null
+++ b/src/utils/TychosLogoIcon.jsx
@@ -0,0 +1,78 @@
+import React from "react";
+
+const TychosLogoIcon = ({
+ size = 20,
+ bgColor = "#181c20",
+ color = "#ffffff",
+ className = "",
+}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default TychosLogoIcon;
diff --git a/src/utils/celestial-functions.js b/src/utils/celestial-functions.js
index 5a452ff..2af7073 100644
--- a/src/utils/celestial-functions.js
+++ b/src/utils/celestial-functions.js
@@ -204,8 +204,8 @@ export function getRaDecDistanceFromPosition(position, scene) {
const sphericalPos = new Spherical();
const lookAtDir = new Vector3(0, 0, 1);
- // Update the scene's matrix world once
- scene.updateMatrixWorld();
+ // REMOVED: scene.updateMatrixWorld();
+ // Optimization: This caused lag spikes. The caller (PosController) must handle matrix updates once per frame.
// Get world positions for celestial sphere and sun
const celestialSphere = scene.getObjectByName("CelestialSphere");
diff --git a/src/utils/time-date-functions.js b/src/utils/time-date-functions.js
index 615c545..829943a 100644
--- a/src/utils/time-date-functions.js
+++ b/src/utils/time-date-functions.js
@@ -15,7 +15,7 @@ const sSecond = sMinute / 60;
export const speedFactOpts = {
seconds: sSecond,
- minute: sMinute,
+ minutes: sMinute,
hours: sHour,
days: sDay,
weeks: sWeek,
@@ -42,7 +42,10 @@ export function getSpeedFact(fact) {
export function posToDays(pos) {
pos += sHour * 12; //Set the clock to tweleve for pos 0
- return Math.floor(pos / sDay);
+ // FIX: Added epsilon (0.00001) ONLY here.
+ // This handles the floating point drift (x.99999 -> x+1) ensuring the day index is correct.
+ // Because we floor here, the result 'g' passed to other functions remains a clean integer.
+ return Math.floor(pos / sDay + 0.00001);
}
export function posToDate(pos) {
@@ -93,6 +96,7 @@ export function timeToPos(value) {
}
export function daysToDate(g) {
+ // REVERTED: No epsilon here. 'g' must remain an integer.
if (g < -152556) return julianCalDayToDate(g); //Julian dates earlier than 1582-10-15
g += 730597;
let y = Math.floor((10000 * g + 14780) / 3652425);
@@ -309,6 +313,7 @@ function julianDateToDays(sDate) {
function julianCalDayToDate(g) {
let jDay = g + 2451717; //+ 10;
+ // REVERTED: No epsilon here.
let z = Math.floor(jDay - 1721116.5);
let r = jDay - 1721116.5 - z;
let year = Math.floor((z - 0.25) / 365.25);
diff --git a/src/utils/useFrameInterval.jsx b/src/utils/useFrameInterval.jsx
index 1cb9e5a..1dda0f3 100644
--- a/src/utils/useFrameInterval.jsx
+++ b/src/utils/useFrameInterval.jsx
@@ -9,9 +9,6 @@ const useFrameInterval = (fn, delay = 10, invalidateFr = false) => {
let delta = current - start;
if (delta >= delay) {
- // Since we have frameloop=demand we sometimes need to force a redraw
- if (invalidateFr) invalidate();
-
// Pass the state (camera, scene, etc.) to the callback function
fn(state);