diff --git a/public/textures/earthbump1k.jpg b/public/textures/earthbump1k.jpg new file mode 100644 index 0000000..7b1a989 Binary files /dev/null and b/public/textures/earthbump1k.jpg differ diff --git a/public/textures/earthmap1k.jpg b/public/textures/earthmap1k.jpg new file mode 100644 index 0000000..7dcab8a Binary files /dev/null and b/public/textures/earthmap1k.jpg differ diff --git a/public/textures/jupitermap.jpg b/public/textures/jupitermap.jpg new file mode 100644 index 0000000..69107df Binary files /dev/null and b/public/textures/jupitermap.jpg differ diff --git a/public/textures/marsbump1k.jpg b/public/textures/marsbump1k.jpg new file mode 100644 index 0000000..873e6c4 Binary files /dev/null and b/public/textures/marsbump1k.jpg differ diff --git a/public/textures/marsmap1k.jpg b/public/textures/marsmap1k.jpg new file mode 100644 index 0000000..4d081bf Binary files /dev/null and b/public/textures/marsmap1k.jpg differ diff --git a/public/textures/mercurybump.jpg b/public/textures/mercurybump.jpg new file mode 100644 index 0000000..c23dd31 Binary files /dev/null and b/public/textures/mercurybump.jpg differ diff --git a/public/textures/mercurymap.jpg b/public/textures/mercurymap.jpg new file mode 100644 index 0000000..9bb2c0b Binary files /dev/null and b/public/textures/mercurymap.jpg differ diff --git a/public/textures/neptunemap.jpg b/public/textures/neptunemap.jpg new file mode 100644 index 0000000..0d0d3f0 Binary files /dev/null and b/public/textures/neptunemap.jpg differ diff --git a/public/textures/plutobump1k.jpg b/public/textures/plutobump1k.jpg new file mode 100644 index 0000000..8128029 Binary files /dev/null and b/public/textures/plutobump1k.jpg differ diff --git a/public/textures/plutomap1k.jpg b/public/textures/plutomap1k.jpg new file mode 100644 index 0000000..d18bf9f Binary files /dev/null and b/public/textures/plutomap1k.jpg differ diff --git a/public/textures/saturnmap.jpg b/public/textures/saturnmap.jpg new file mode 100644 index 0000000..cc9de41 Binary files /dev/null and b/public/textures/saturnmap.jpg differ diff --git a/public/textures/uranusmap.jpg b/public/textures/uranusmap.jpg new file mode 100644 index 0000000..aad43fb Binary files /dev/null and b/public/textures/uranusmap.jpg differ diff --git a/public/textures/venusbump.jpg b/public/textures/venusbump.jpg new file mode 100644 index 0000000..551bbd3 Binary files /dev/null and b/public/textures/venusbump.jpg differ diff --git a/public/textures/venusmap.jpg b/public/textures/venusmap.jpg new file mode 100644 index 0000000..699f280 Binary files /dev/null and b/public/textures/venusmap.jpg differ diff --git a/src/App.tsx b/src/App.tsx index 035c394..7332066 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,145 +4,64 @@ */ import { useState, useEffect } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; +import { PLANETS } from './data/planets'; import PlanetScene from './components/PlanetScene'; +import PlanetInfoPanel from './components/PlanetInfoPanel'; +import PlanetSidebar from './components/PlanetSidebar'; +import NextPlanetHint from './components/NextPlanetHint'; -const PLANETS = [ - { id: 'pluto', name: 'PLUTO', distance: '39.5 AU', color: '#8B7355', description: 'ONCE CONSIDERED THE NINTH PLANET, PLUTO IS A DWARF PLANET IN THE KUIPER BELT. IT HAS A COMPLEX SURFACE WITH MOUNTAINS OF WATER ICE.' }, - { id: 'neptune', name: 'NEPTUNE', distance: '30.06 AU', color: '#274687', description: 'DARK, COLD, AND WHIPPED BY SUPERSONIC WINDS, ICE GIANT NEPTUNE IS THE EIGHTH AND MOST DISTANT PLANET IN OUR SOLAR SYSTEM.' }, - { id: 'uranus', name: 'URANUS', distance: '19.18 AU', color: '#82b3d1', description: 'URANUS IS THE SEVENTH PLANET FROM THE SUN. IT HAS THE THIRD-LARGEST PLANETARY RADIUS AND FOURTH-LARGEST PLANETARY MASS.' }, - { id: 'saturn', name: 'SATURN', distance: '9.539 AU', color: '#e3cb8f', description: 'ADORNED WITH A DAZZLING, COMPLEX SYSTEM OF ICY RINGS, SATURN IS UNIQUE IN OUR SOLAR SYSTEM. IT IS THE SECOND-LARGEST PLANET.' }, - { id: 'jupiter', name: 'JUPITER', distance: '5.203 AU', color: '#c99b75', description: 'JUPITER IS MORE THAN TWICE AS MASSIVE THAN THE OTHER PLANETS OF OUR SOLAR SYSTEM COMBINED. THE GIANT PLANET\'S GREAT RED SPOT IS A CENTURIES-OLD STORM.' }, - { id: 'mars', name: 'MARS', distance: '1.524 AU', color: '#c1440e', description: 'MARS IS A DUSTY, COLD, DESERT WORLD WITH A VERY THIN ATMOSPHERE. THERE IS STRONG EVIDENCE MARS WAS - BILLIONS OF YEARS AGO - WETTER AND WARMER.' }, - { id: 'earth', name: 'EARTH', distance: '1 AU', color: '#4b759e', description: 'OUR HOME PLANET IS THE ONLY PLACE WE KNOW OF SO FAR THAT\'S INHABITED BY LIVING THINGS. IT\'S ALSO THE ONLY PLANET IN OUR SOLAR SYSTEM WITH LIQUID WATER ON THE SURFACE.' }, - { id: 'venus', name: 'VENUS', distance: '0.723 AU', color: '#e89c51', description: 'NAMED FOR THE ROMAN GODDESS OF LOVE AND BEAUTY. IN ANCIENT TIMES, VENUS WAS OFTEN THOUGHT TO BE TWO DIFFERENT STARS, THE EVENING STAR AND THE MORNING STAR.' }, - { id: 'mercury', name: 'MERCURY', distance: '0.39 AU', color: '#888888', description: 'THE SMALLEST PLANET IN OUR SOLAR SYSTEM AND NEAREST TO THE SUN, MERCURY IS ONLY SLIGHTLY LARGER THAN EARTH\'S MOON.' }, -]; +const TRANSITION_DURATION_MS = 2200; export default function App() { - const [activeIndex, setActiveIndex] = useState(7); // Venus is default + const [activeIndex, setActiveIndex] = useState(7); const [isTransitioning, setIsTransitioning] = useState(false); useEffect(() => { setIsTransitioning(true); - const timer = setTimeout(() => { - setIsTransitioning(false); - }, 2200); // Wait for the 2.5s GSAP animation to mostly finish + const timer = setTimeout(() => setIsTransitioning(false), TRANSITION_DURATION_MS); return () => clearTimeout(timer); }, [activeIndex]); const activePlanet = PLANETS[activeIndex]; + const hasPreviousPlanet = activeIndex > 0; return (
- {/* Top Header */} + {/* Header */}

SOLAR EXPLORER

IN ONLY THREE.JS

- {/* Top Planet Bottom Half Shadow Overlay */} - {activeIndex > 0 && ( -
)} - {/* Center Content (Next Planet Name) */} - {activeIndex > 0 && ( -
setActiveIndex(activeIndex - 1)} - > - - -

PLANET

-

- {PLANETS[activeIndex - 1].name} -

-
-
-
+ /> )} - {/* Bottom Content (Active Planet Info) */} -
- - -

PLANET

-

{activePlanet.name}

- -

- {activePlanet.description} -

- - -
-
-
+ - {/* Left Sidebar */} -
- {PLANETS.map((planet, idx) => { - const isActive = idx === activeIndex; - return ( - - ); - })} -
+
); } diff --git a/src/components/NextPlanetHint.tsx b/src/components/NextPlanetHint.tsx new file mode 100644 index 0000000..c0c5f23 --- /dev/null +++ b/src/components/NextPlanetHint.tsx @@ -0,0 +1,30 @@ +import { motion, AnimatePresence } from 'motion/react'; +import type { Planet } from '../data/planets'; + +interface NextPlanetHintProps { + planet: Planet; + activeIndex: number; + onClick: () => void; +} + +export default function NextPlanetHint({ planet, activeIndex, onClick }: NextPlanetHintProps) { + return ( +
+ + +

PLANET

+

{planet.name}

+
+
+
+ ); +} diff --git a/src/components/PlanetInfoPanel.tsx b/src/components/PlanetInfoPanel.tsx new file mode 100644 index 0000000..a21488b --- /dev/null +++ b/src/components/PlanetInfoPanel.tsx @@ -0,0 +1,38 @@ +import { motion, AnimatePresence } from 'motion/react'; +import type { Planet } from '../data/planets'; + +interface PlanetInfoPanelProps { + planet: Planet; + activeIndex: number; +} + +export default function PlanetInfoPanel({ planet, activeIndex }: PlanetInfoPanelProps) { + return ( +
+ + +

+ PLANET +

+

+ {planet.name} +

+ +

+ {planet.description} +

+ + +
+
+
+ ); +} diff --git a/src/components/PlanetScene.tsx b/src/components/PlanetScene.tsx index 71bdf47..1af7797 100644 --- a/src/components/PlanetScene.tsx +++ b/src/components/PlanetScene.tsx @@ -1,429 +1,416 @@ -import { useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import * as THREE from 'three'; import gsap from 'gsap'; +import { + SCENE_PLANETS, + PLANET_SPACING, + TEXTURE_URLS, + BUMP_URLS, + toSceneIndex, + type Planet, +} from '../data/planets'; +import { createAtmosphereMaterial } from '../shaders/atmosphere'; +import { generateNoiseTexture } from '../utils/textures'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PlanetSceneProps { + activeIndex: number; + onPlanetChange: (index: number) => void; +} + +interface SceneRefs { + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + cameraRig: THREE.Group; + renderer: THREE.WebGLRenderer; + rimLight: THREE.DirectionalLight; + planets: THREE.Mesh[]; + atmospheres: THREE.Mesh[]; + lookAtTarget: THREE.Vector3; +} + +// --------------------------------------------------------------------------- +// Scene builders (pure functions that return Three.js objects) +// --------------------------------------------------------------------------- + +function createRenderer(container: HTMLElement): THREE.WebGLRenderer { + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: 'high-performance', + }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + container.appendChild(renderer.domElement); + return renderer; +} + +function createCameraRig( + scene: THREE.Scene, + planet: Planet, + sceneIndex: number, +): { cameraRig: THREE.Group; camera: THREE.PerspectiveCamera; lookAtTarget: THREE.Vector3 } { + const cameraRig = new THREE.Group(); + scene.add(cameraRig); + + const camera = new THREE.PerspectiveCamera( + 45, + window.innerWidth / window.innerHeight, + 0.1, + 3000, + ); + cameraRig.add(camera); + + const targetZ = -sceneIndex * PLANET_SPACING; + const lookAtZ = -(sceneIndex + 1) * PLANET_SPACING; + const camY = planet.radius * 1.6; + const camZRelative = planet.radius * 1.7; + + cameraRig.position.set(0, 0, targetZ); + camera.position.set(0, camY, camZRelative); + + const lookAtTarget = new THREE.Vector3(0, -planet.radius * 2.2, lookAtZ); + camera.lookAt(lookAtTarget); + + return { cameraRig, camera, lookAtTarget }; +} -const PLANETS = [ - { id: 'pluto', name: 'PLUTO', distance: '39.5 AU', color: '#8B7355', radius: 30 }, - { id: 'neptune', name: 'NEPTUNE', distance: '30.06 AU', color: '#274687', radius: 30 }, - { id: 'uranus', name: 'URANUS', distance: '19.18 AU', color: '#82b3d1', radius: 30 }, - { id: 'saturn', name: 'SATURN', distance: '9.539 AU', color: '#e3cb8f', radius: 30 }, - { id: 'jupiter', name: 'JUPITER', distance: '5.203 AU', color: '#c99b75', radius: 30 }, - { id: 'mars', name: 'MARS', distance: '1.524 AU', color: '#c1440e', radius: 30 }, - { id: 'earth', name: 'EARTH', distance: '1 AU', color: '#4b759e', radius: 30 }, - { id: 'venus', name: 'VENUS', distance: '0.723 AU', color: '#e89c51', radius: 30 }, - { id: 'mercury', name: 'MERCURY', distance: '0.39 AU', color: '#888888', radius: 30 }, -]; - -// Reverse so Mercury is at Z=0, Venus at Z=-400, etc. -const SCENE_PLANETS = [...PLANETS].reverse(); -const SPACING = 400; - -const BASE_URL = 'https://cdn.jsdelivr.net/gh/jeromeetienne/threex.planets@master/images/'; -const TEXTURE_URLS: Record = { - mercury: BASE_URL + 'mercurymap.jpg', - venus: BASE_URL + 'venusmap.jpg', - earth: BASE_URL + 'earthmap1k.jpg', - mars: BASE_URL + 'marsmap1k.jpg', - jupiter: BASE_URL + 'jupitermap.jpg', - saturn: BASE_URL + 'saturnmap.jpg', - uranus: BASE_URL + 'uranusmap.jpg', - neptune: BASE_URL + 'neptunemap.jpg', - pluto: BASE_URL + 'plutomap1k.jpg', -}; - -const BUMP_URLS: Record = { - mercury: BASE_URL + 'mercurybump.jpg', - venus: BASE_URL + 'venusbump.jpg', - earth: BASE_URL + 'earthbump1k.jpg', - mars: BASE_URL + 'marsbump1k.jpg', - pluto: BASE_URL + 'plutobump1k.jpg', -}; - -// Simple procedural noise texture generator -function generateNoiseTexture(baseColor: string) { - const canvas = document.createElement('canvas'); - canvas.width = 512; - canvas.height = 512; - const ctx = canvas.getContext('2d')!; - - ctx.fillStyle = baseColor; - ctx.fillRect(0, 0, 512, 512); - - const imageData = ctx.getImageData(0, 0, 512, 512); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const noise = (Math.random() - 0.5) * 30; - data[i] = Math.min(255, Math.max(0, data[i] + noise)); - data[i+1] = Math.min(255, Math.max(0, data[i+1] + noise)); - data[i+2] = Math.min(255, Math.max(0, data[i+2] + noise)); +function createLighting( + cameraRig: THREE.Group, + scene: THREE.Scene, + initialColor: string, +): { rimLight: THREE.DirectionalLight } { + const ambient = new THREE.AmbientLight(0xffffff, 0.02); + scene.add(ambient); + + const sunLight = new THREE.DirectionalLight(0xffffff, 2.5); + sunLight.position.set(0, 50, 100); + sunLight.castShadow = true; + Object.assign(sunLight.shadow, { + mapSize: new THREE.Vector2(2048, 2048), + bias: -0.001, + }); + Object.assign(sunLight.shadow.camera, { + near: 0.5, + far: 1000, + left: -100, + right: 100, + top: 100, + bottom: -100, + }); + cameraRig.add(sunLight); + cameraRig.add(sunLight.target); + sunLight.target.position.set(0, 0, 0); + + const rimLight = new THREE.DirectionalLight(initialColor, 6); + rimLight.position.set(0, 50, -100); + cameraRig.add(rimLight); + cameraRig.add(rimLight.target); + rimLight.target.position.set(0, 0, 0); + + return { rimLight }; +} + +function createPlanetMesh( + planet: Planet, + zPos: number, + loader: THREE.TextureLoader, +): THREE.Mesh { + const geometry = new THREE.SphereGeometry(planet.radius, 128, 128); + + const hasTexture = Boolean(TEXTURE_URLS[planet.id]); + const texture = hasTexture + ? loader.load(TEXTURE_URLS[planet.id]) + : generateNoiseTexture(planet.color); + + if (hasTexture && texture instanceof THREE.Texture) { + texture.colorSpace = THREE.SRGBColorSpace; } - - ctx.putImageData(imageData, 0, 0); - - const texture = new THREE.CanvasTexture(canvas); - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - return texture; + + const bumpTexture = BUMP_URLS[planet.id] + ? loader.load(BUMP_URLS[planet.id]) + : undefined; + + const skipBump = ['uranus', 'neptune'].includes(planet.id); + + const material = new THREE.MeshStandardMaterial({ + map: texture, + color: hasTexture ? 0xffffff : planet.color, + roughness: 0.8, + metalness: 0.1, + bumpMap: bumpTexture ?? (skipBump ? null : texture), + bumpScale: bumpTexture ? (planet.id === 'mercury' ? 0.5 : 2.0) : 0.5, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.position.set(0, 0, zPos); + mesh.rotation.y = Math.random() * Math.PI; + mesh.rotation.z = Math.random() * 0.2; + + return mesh; +} + +function createAtmosphereMesh(planet: Planet, zPos: number): THREE.Mesh { + const geometry = new THREE.SphereGeometry(planet.radius * 1.15, 64, 64); + const material = createAtmosphereMaterial(planet.color); + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(0, 0, zPos); + return mesh; +} + +function populateScene(scene: THREE.Scene): { planets: THREE.Mesh[]; atmospheres: THREE.Mesh[] } { + const loader = new THREE.TextureLoader(); + const planets: THREE.Mesh[] = []; + const atmospheres: THREE.Mesh[] = []; + + SCENE_PLANETS.forEach((planet, i) => { + const zPos = -i * PLANET_SPACING; + + const planetMesh = createPlanetMesh(planet, zPos, loader); + scene.add(planetMesh); + planets.push(planetMesh); + + const atmosMesh = createAtmosphereMesh(planet, zPos); + scene.add(atmosMesh); + atmospheres.push(atmosMesh); + }); + + return { planets, atmospheres }; } -export default function PlanetScene({ activeIndex, onPlanetChange }: { activeIndex: number, onPlanetChange: (idx: number) => void }) { +// --------------------------------------------------------------------------- +// Interaction helpers +// --------------------------------------------------------------------------- + +const CLICK_THRESHOLD = 5; + +function setupMouseInteraction( + renderer: THREE.WebGLRenderer, + camera: THREE.PerspectiveCamera, + planets: THREE.Mesh[], + activeIndexRef: React.MutableRefObject, + onPlanetChange: (index: number) => void, +) { + let isDragging = false; + let prevMouse = { x: 0, y: 0 }; + let clickStart = { x: 0, y: 0 }; + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + + const onMouseDown = (e: MouseEvent) => { + isDragging = true; + prevMouse = { x: e.clientX, y: e.clientY }; + clickStart = { x: e.clientX, y: e.clientY }; + }; + + const onMouseUp = (e: MouseEvent) => { + isDragging = false; + + const dx = Math.abs(e.clientX - clickStart.x); + const dy = Math.abs(e.clientY - clickStart.y); + if (dx >= CLICK_THRESHOLD || dy >= CLICK_THRESHOLD) return; + + pointer.x = (e.clientX / window.innerWidth) * 2 - 1; + pointer.y = -(e.clientY / window.innerHeight) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + + const hits = raycaster.intersectObjects(planets); + if (hits.length === 0) return; + + const idx = planets.indexOf(hits[0].object as THREE.Mesh); + if (idx !== -1) { + onPlanetChange(SCENE_PLANETS.length - 1 - idx); + } + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - prevMouse.x; + const dy = e.clientY - prevMouse.y; + + const activeMesh = planets[toSceneIndex(activeIndexRef.current)]; + if (activeMesh) { + activeMesh.rotation.y += dx * 0.005; + activeMesh.rotation.x += dy * 0.005; + } + prevMouse = { x: e.clientX, y: e.clientY }; + }; + + renderer.domElement.addEventListener('mousedown', onMouseDown); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('mousemove', onMouseMove); + + return { + isDraggingRef: () => isDragging, + cleanup() { + renderer.domElement.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('mousemove', onMouseMove); + }, + }; +} + +// --------------------------------------------------------------------------- +// Transition animation +// --------------------------------------------------------------------------- + +function animateTransition(refs: SceneRefs, activeIndex: number) { + gsap.killTweensOf(refs.camera.position); + gsap.killTweensOf(refs.cameraRig.position); + gsap.killTweensOf(refs.lookAtTarget); + + const sceneIndex = toSceneIndex(activeIndex); + const planet = SCENE_PLANETS[sceneIndex]; + const targetZ = -sceneIndex * PLANET_SPACING; + const lookAtZ = -(sceneIndex + 1) * PLANET_SPACING; + + const duration = 2.5; + const ease = 'power3.inOut'; + + gsap.to(refs.cameraRig.position, { z: targetZ, duration, ease }); + gsap.to(refs.camera.position, { + x: 0, + y: planet.radius * 1.6, + z: planet.radius * 1.7, + duration, + ease, + }); + gsap.to(refs.lookAtTarget, { + x: 0, + y: -planet.radius * 2.2, + z: lookAtZ, + duration, + ease, + }); + + const targetColor = new THREE.Color(planet.color); + gsap.to(refs.rimLight.color, { + r: targetColor.r, + g: targetColor.g, + b: targetColor.b, + duration, + ease, + }); +} + +// --------------------------------------------------------------------------- +// Disposal +// --------------------------------------------------------------------------- + +function disposeScene(refs: SceneRefs, container: HTMLElement) { + if (container && refs.renderer.domElement.parentNode === container) { + container.removeChild(refs.renderer.domElement); + } + refs.renderer.dispose(); + + [...refs.planets, ...refs.atmospheres].forEach((mesh) => { + mesh.geometry.dispose(); + (mesh.material as THREE.Material).dispose(); + }); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function PlanetScene({ activeIndex, onPlanetChange }: PlanetSceneProps) { const containerRef = useRef(null); - const sceneRef = useRef(null); - const cameraRef = useRef(null); - const cameraRigRef = useRef(null); - const rendererRef = useRef(null); - const rimLightRef = useRef(null); - const planetsRef = useRef([]); - const atmospheresRef = useRef([]); - - const lookAtTargetRef = useRef(new THREE.Vector3(0, 0, 0)); + const refsRef = useRef(null); const activeIndexRef = useRef(activeIndex); - const prevSceneIndexRef = useRef(SCENE_PLANETS.length - 1 - activeIndex); useEffect(() => { activeIndexRef.current = activeIndex; }, [activeIndex]); + // --- One-time scene initialisation --- useEffect(() => { const container = containerRef.current; if (!container) return; - // Scene setup const scene = new THREE.Scene(); scene.background = new THREE.Color('#000000'); - // Deep space fog - linear fog to strictly hide all planets behind the active one scene.fog = new THREE.Fog('#000000', 300, 800); - sceneRef.current = scene; - - // Camera setup - const cameraRig = new THREE.Group(); - cameraRigRef.current = cameraRig; - scene.add(cameraRig); - - const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 3000); - cameraRef.current = camera; - cameraRig.add(camera); - - // Initial Camera Position - const initialSceneIndex = SCENE_PLANETS.length - 1 - activeIndex; - const initialActivePlanet = SCENE_PLANETS[initialSceneIndex]; - const initialTargetZ = -initialSceneIndex * SPACING; - const initialLookAtIndex = initialSceneIndex + 1; - const initialLookAtZ = -initialLookAtIndex * SPACING; - - const initialCamY = initialActivePlanet.radius * 1.6; - const initialCamZRelative = initialActivePlanet.radius * 1.7; - - cameraRig.position.set(0, 0, initialTargetZ); - camera.position.set(0, initialCamY, initialCamZRelative); - lookAtTargetRef.current.set(0, -initialActivePlanet.radius * 2.2, initialLookAtZ); - camera.lookAt(lookAtTargetRef.current); - - // Renderer setup - const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" }); - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.0; - renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; - container.appendChild(renderer.domElement); - rendererRef.current = renderer; - - // Lighting - const ambientLight = new THREE.AmbientLight(0xffffff, 0.02); - scene.add(ambientLight); - - // Main sun light (directional) attached to cameraRig - const sunLight = new THREE.DirectionalLight(0xffffff, 2.5); - sunLight.position.set(0, 50, 100); // Move light further back so shadow camera covers the planet - sunLight.castShadow = true; - sunLight.shadow.mapSize.width = 2048; - sunLight.shadow.mapSize.height = 2048; - sunLight.shadow.camera.near = 0.5; - sunLight.shadow.camera.far = 1000; - sunLight.shadow.camera.left = -100; - sunLight.shadow.camera.right = 100; - sunLight.shadow.camera.top = 100; - sunLight.shadow.camera.bottom = -100; - sunLight.shadow.bias = -0.001; - cameraRig.add(sunLight); - - // Strong rim light from above and behind the active planet - const rimLight = new THREE.DirectionalLight(initialActivePlanet.color, 6); - rimLight.position.set(0, 50, -100); // Light from behind - cameraRig.add(rimLight); - rimLightRef.current = rimLight; - - // Add targets to cameraRig so lights always point relative to the camera - cameraRig.add(sunLight.target); - sunLight.target.position.set(0, 0, 0); - - cameraRig.add(rimLight.target); - rimLight.target.position.set(0, 0, 0); - - const textureLoader = new THREE.TextureLoader(); - - // Create Planets - SCENE_PLANETS.forEach((p, i) => { - const zPos = -i * SPACING; - - const geometry = new THREE.SphereGeometry(p.radius, 128, 128); - - // Try to load texture, fallback to procedural - let texture; - let bumpTexture; - if (TEXTURE_URLS[p.id]) { - texture = textureLoader.load(TEXTURE_URLS[p.id]); - texture.colorSpace = THREE.SRGBColorSpace; - } else { - texture = generateNoiseTexture(p.color); - } - - if (BUMP_URLS[p.id]) { - bumpTexture = textureLoader.load(BUMP_URLS[p.id]); - } - - const material = new THREE.MeshStandardMaterial({ - map: texture, - color: TEXTURE_URLS[p.id] ? 0xffffff : p.color, - roughness: 0.8, - metalness: 0.1, - bumpMap: bumpTexture || (['uranus', 'neptune'].includes(p.id) ? null : texture), - bumpScale: bumpTexture ? (p.id === 'mercury' ? 0.5 : 2.0) : 0.5, - }); - - const mesh = new THREE.Mesh(geometry, material); - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.position.set(0, 0, zPos); - mesh.rotation.y = Math.random() * Math.PI; - // Tilt axis slightly - mesh.rotation.z = Math.random() * 0.2; - scene.add(mesh); - planetsRef.current.push(mesh); - - // Atmosphere glow (Fresnel-like effect using custom shader) - const atmosGeom = new THREE.SphereGeometry(p.radius * 1.15, 64, 64); - const atmosMat = new THREE.ShaderMaterial({ - uniforms: THREE.UniformsUtils.merge([ - THREE.UniformsLib['fog'], - { - c: { value: 0.3 }, - p: { value: 4.0 }, - glowColor: { value: new THREE.Color(p.color) } - } - ]), - vertexShader: ` - varying vec3 vNormal; - varying vec3 vPositionNormal; - ${THREE.ShaderChunk.fog_pars_vertex} - void main() { - vNormal = normalize( normalMatrix * normal ); - vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 ); - vPositionNormal = normalize( -mvPosition.xyz ); - gl_Position = projectionMatrix * mvPosition; - ${THREE.ShaderChunk.fog_vertex} - } - `, - fragmentShader: ` - uniform float c; - uniform float p; - uniform vec3 glowColor; - varying vec3 vNormal; - varying vec3 vPositionNormal; - ${THREE.ShaderChunk.fog_pars_fragment} - void main() { - float intensity = pow( max(0.0, c - dot(vNormal, vPositionNormal)), p ); - vec3 glow = glowColor * intensity * 2.0; - gl_FragColor = vec4( glow, intensity * 1.5 ); - ${THREE.ShaderChunk.fog_fragment} - } - `, - side: THREE.BackSide, - blending: THREE.AdditiveBlending, - transparent: true, - depthWrite: false, - fog: true, - }); - - const atmosMesh = new THREE.Mesh(atmosGeom, atmosMat); - atmosMesh.position.set(0, 0, zPos); - scene.add(atmosMesh); - atmospheresRef.current.push(atmosMesh); - }); - - // Mouse Drag to Rotate and Click to Select - let isDragging = false; - let previousMousePosition = { x: 0, y: 0 }; - let clickStartX = 0; - let clickStartY = 0; - const raycaster = new THREE.Raycaster(); - const mouse = new THREE.Vector2(); - - const onMouseDown = (event: MouseEvent) => { - isDragging = true; - previousMousePosition = { x: event.clientX, y: event.clientY }; - clickStartX = event.clientX; - clickStartY = event.clientY; - }; - const onMouseUp = (event: MouseEvent) => { - isDragging = false; - - // Handle click for planet selection - if (Math.abs(event.clientX - clickStartX) < 5 && Math.abs(event.clientY - clickStartY) < 5) { - mouse.x = (event.clientX / window.innerWidth) * 2 - 1; - mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; - raycaster.setFromCamera(mouse, camera); - - const intersects = raycaster.intersectObjects(planetsRef.current); - if (intersects.length > 0) { - const clickedMesh = intersects[0].object; - const index = planetsRef.current.indexOf(clickedMesh as THREE.Mesh); - if (index !== -1) { - const originalIndex = SCENE_PLANETS.length - 1 - index; - onPlanetChange(originalIndex); - } - } - } + const initialSceneIndex = toSceneIndex(activeIndex); + const initialPlanet = SCENE_PLANETS[initialSceneIndex]; + + const { cameraRig, camera, lookAtTarget } = createCameraRig( + scene, + initialPlanet, + initialSceneIndex, + ); + const renderer = createRenderer(container); + const { rimLight } = createLighting(cameraRig, scene, initialPlanet.color); + const { planets, atmospheres } = populateScene(scene); + + const refs: SceneRefs = { + scene, + camera, + cameraRig, + renderer, + rimLight, + planets, + atmospheres, + lookAtTarget, }; - - const onMouseMove = (event: MouseEvent) => { - // Manual Rotation - if (isDragging) { - const deltaMove = { - x: event.clientX - previousMousePosition.x, - y: event.clientY - previousMousePosition.y - }; - - const activeMesh = planetsRef.current[SCENE_PLANETS.length - 1 - activeIndexRef.current]; - if (activeMesh) { - activeMesh.rotation.y += deltaMove.x * 0.005; - activeMesh.rotation.x += deltaMove.y * 0.005; - } - - previousMousePosition = { x: event.clientX, y: event.clientY }; - } - }; - - renderer.domElement.addEventListener('mousedown', onMouseDown); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('mousemove', onMouseMove); - - // Animation Loop - let animationFrameId: number; - const render = () => { - const currentActiveSceneIndex = SCENE_PLANETS.length - 1 - activeIndexRef.current; - - planetsRef.current.forEach((p, i) => { - if (!isDragging || i !== currentActiveSceneIndex) { - p.rotation.y += 0.002; // Auto rotation + refsRef.current = refs; + + const { isDraggingRef, cleanup: cleanupMouse } = setupMouseInteraction( + renderer, + camera, + planets, + activeIndexRef, + onPlanetChange, + ); + + // --- Animation loop --- + let frameId: number; + const tick = () => { + const activeSceneIdx = toSceneIndex(activeIndexRef.current); + planets.forEach((p, i) => { + if (!isDraggingRef() || i !== activeSceneIdx) { + p.rotation.y += 0.002; } }); - - camera.lookAt(lookAtTargetRef.current); - + camera.lookAt(lookAtTarget); renderer.render(scene, camera); - animationFrameId = requestAnimationFrame(render); + frameId = requestAnimationFrame(tick); }; - render(); + tick(); - // Resize Handler + // --- Resize --- const handleResize = () => { - if (!container) return; - const width = container.clientWidth; - const height = container.clientHeight; - camera.aspect = width / height; + const { clientWidth: w, clientHeight: h } = container; + camera.aspect = w / h; camera.updateProjectionMatrix(); - renderer.setSize(width, height); + renderer.setSize(w, h); }; window.addEventListener('resize', handleResize); - handleResize(); // Call once to set initial size correctly + handleResize(); return () => { window.removeEventListener('resize', handleResize); - renderer.domElement.removeEventListener('mousedown', onMouseDown); - window.removeEventListener('mouseup', onMouseUp); - window.removeEventListener('mousemove', onMouseMove); - cancelAnimationFrame(animationFrameId); - if (container && renderer.domElement) { - container.removeChild(renderer.domElement); - } - renderer.dispose(); - - // Dispose geometries and materials - planetsRef.current.forEach(p => { - p.geometry.dispose(); - (p.material as THREE.Material).dispose(); - }); - atmospheresRef.current.forEach(a => { - a.geometry.dispose(); - (a.material as THREE.Material).dispose(); - }); - - planetsRef.current = []; - atmospheresRef.current = []; + cleanupMouse(); + cancelAnimationFrame(frameId); + disposeScene(refs, container); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Handle Planet Change Transition + // --- Planet transition animation --- useEffect(() => { - if (!cameraRef.current || !sceneRef.current || !cameraRigRef.current) return; - - gsap.killTweensOf(cameraRef.current.position); - gsap.killTweensOf(cameraRigRef.current.position); - gsap.killTweensOf(lookAtTargetRef.current); - - const sceneIndex = SCENE_PLANETS.length - 1 - activeIndex; - const activePlanet = SCENE_PLANETS[sceneIndex]; - const targetZ = -sceneIndex * SPACING; - - const lookAtIndex = sceneIndex + 1; - const lookAtZ = -lookAtIndex * SPACING; - - const prevSceneIndex = prevSceneIndexRef.current; - - // Position camera to create the massive horizon effect - const camY = activePlanet.radius * 1.6; - const camZRelative = activePlanet.radius * 1.7; - - gsap.to(cameraRigRef.current.position, { - z: targetZ, - duration: 2.5, - ease: "power3.inOut" - }); - - gsap.to(cameraRef.current.position, { - x: 0, - y: camY, - z: camZRelative, - duration: 2.5, - ease: "power3.inOut" - }); - - gsap.to(lookAtTargetRef.current, { - x: 0, - y: -activePlanet.radius * 2.2, // Push the next planet below the label text - z: lookAtZ, - duration: 2.5, - ease: "power3.inOut" - }); - - if (rimLightRef.current) { - const targetColor = new THREE.Color(activePlanet.color); - gsap.to(rimLightRef.current.color, { - r: targetColor.r, - g: targetColor.g, - b: targetColor.b, - duration: 2.5, - ease: "power3.inOut" - }); + if (refsRef.current) { + animateTransition(refsRef.current, activeIndex); } - - prevSceneIndexRef.current = sceneIndex; - }, [activeIndex]); return
; diff --git a/src/components/PlanetSidebar.tsx b/src/components/PlanetSidebar.tsx new file mode 100644 index 0000000..93f7d71 --- /dev/null +++ b/src/components/PlanetSidebar.tsx @@ -0,0 +1,80 @@ +import type { Planet } from '../data/planets'; + +interface PlanetSidebarProps { + planets: Planet[]; + activeIndex: number; + onSelect: (index: number) => void; +} + +function PlanetDot({ isActive }: { isActive: boolean }) { + return ( +
+
+ {isActive &&
} +
+ ); +} + +function PlanetThumbnail({ planet }: { planet: Planet }) { + return ( +
+ ); +} + +export default function PlanetSidebar({ planets, activeIndex, onSelect }: PlanetSidebarProps) { + return ( +
+ {planets.map((planet, idx) => { + const isActive = idx === activeIndex; + return ( + + ); + })} +
+ ); +} diff --git a/src/data/planets.ts b/src/data/planets.ts new file mode 100644 index 0000000..fb37e4b --- /dev/null +++ b/src/data/planets.ts @@ -0,0 +1,131 @@ +export interface Planet { + id: string; + name: string; + distance: string; + color: string; + radius: number; + description: string; +} + +/** + * Ordered from outermost (Pluto) to innermost (Mercury). + * This is the UI order used for the sidebar and active index. + */ +export const PLANETS: Planet[] = [ + { + id: 'pluto', + name: 'PLUTO', + distance: '39.5 AU', + color: '#8B7355', + radius: 30, + description: + 'ONCE CONSIDERED THE NINTH PLANET, PLUTO IS A DWARF PLANET IN THE KUIPER BELT. IT HAS A COMPLEX SURFACE WITH MOUNTAINS OF WATER ICE.', + }, + { + id: 'neptune', + name: 'NEPTUNE', + distance: '30.06 AU', + color: '#274687', + radius: 30, + description: + 'DARK, COLD, AND WHIPPED BY SUPERSONIC WINDS, ICE GIANT NEPTUNE IS THE EIGHTH AND MOST DISTANT PLANET IN OUR SOLAR SYSTEM.', + }, + { + id: 'uranus', + name: 'URANUS', + distance: '19.18 AU', + color: '#82b3d1', + radius: 30, + description: + 'URANUS IS THE SEVENTH PLANET FROM THE SUN. IT HAS THE THIRD-LARGEST PLANETARY RADIUS AND FOURTH-LARGEST PLANETARY MASS.', + }, + { + id: 'saturn', + name: 'SATURN', + distance: '9.539 AU', + color: '#e3cb8f', + radius: 30, + description: + 'ADORNED WITH A DAZZLING, COMPLEX SYSTEM OF ICY RINGS, SATURN IS UNIQUE IN OUR SOLAR SYSTEM. IT IS THE SECOND-LARGEST PLANET.', + }, + { + id: 'jupiter', + name: 'JUPITER', + distance: '5.203 AU', + color: '#c99b75', + radius: 30, + description: + "JUPITER IS MORE THAN TWICE AS MASSIVE THAN THE OTHER PLANETS OF OUR SOLAR SYSTEM COMBINED. THE GIANT PLANET'S GREAT RED SPOT IS A CENTURIES-OLD STORM.", + }, + { + id: 'mars', + name: 'MARS', + distance: '1.524 AU', + color: '#c1440e', + radius: 30, + description: + 'MARS IS A DUSTY, COLD, DESERT WORLD WITH A VERY THIN ATMOSPHERE. THERE IS STRONG EVIDENCE MARS WAS - BILLIONS OF YEARS AGO - WETTER AND WARMER.', + }, + { + id: 'earth', + name: 'EARTH', + distance: '1 AU', + color: '#4b759e', + radius: 30, + description: + "OUR HOME PLANET IS THE ONLY PLACE WE KNOW OF SO FAR THAT'S INHABITED BY LIVING THINGS. IT'S ALSO THE ONLY PLANET IN OUR SOLAR SYSTEM WITH LIQUID WATER ON THE SURFACE.", + }, + { + id: 'venus', + name: 'VENUS', + distance: '0.723 AU', + color: '#e89c51', + radius: 30, + description: + 'NAMED FOR THE ROMAN GODDESS OF LOVE AND BEAUTY. IN ANCIENT TIMES, VENUS WAS OFTEN THOUGHT TO BE TWO DIFFERENT STARS, THE EVENING STAR AND THE MORNING STAR.', + }, + { + id: 'mercury', + name: 'MERCURY', + distance: '0.39 AU', + color: '#888888', + radius: 30, + description: + "THE SMALLEST PLANET IN OUR SOLAR SYSTEM AND NEAREST TO THE SUN, MERCURY IS ONLY SLIGHTLY LARGER THAN EARTH'S MOON.", + }, +]; + +/** + * Scene order is reversed so Mercury (innermost) sits at Z=0 + * and each successive planet is further along the negative Z axis. + */ +export const SCENE_PLANETS = [...PLANETS].reverse(); + +export const PLANET_SPACING = 400; + +const TEXTURES = '/textures/'; + +export const TEXTURE_URLS: Record = { + mercury: TEXTURES + 'mercurymap.jpg', + venus: TEXTURES + 'venusmap.jpg', + earth: TEXTURES + 'earthmap1k.jpg', + mars: TEXTURES + 'marsmap1k.jpg', + jupiter: TEXTURES + 'jupitermap.jpg', + saturn: TEXTURES + 'saturnmap.jpg', + uranus: TEXTURES + 'uranusmap.jpg', + neptune: TEXTURES + 'neptunemap.jpg', + pluto: TEXTURES + 'plutomap1k.jpg', +}; + +export const BUMP_URLS: Record = { + mercury: TEXTURES + 'mercurybump.jpg', + venus: TEXTURES + 'venusbump.jpg', + earth: TEXTURES + 'earthbump1k.jpg', + mars: TEXTURES + 'marsbump1k.jpg', + pluto: TEXTURES + 'plutobump1k.jpg', +}; + +/** Convert a UI-level planet index to the scene-level index. */ +export function toSceneIndex(uiIndex: number): number { + return SCENE_PLANETS.length - 1 - uiIndex; +} diff --git a/src/shaders/atmosphere.ts b/src/shaders/atmosphere.ts new file mode 100644 index 0000000..1882f51 --- /dev/null +++ b/src/shaders/atmosphere.ts @@ -0,0 +1,49 @@ +import * as THREE from 'three'; + +const ATMOSPHERE_VERTEX = /* glsl */ ` + varying vec3 vNormal; + varying vec3 vPositionNormal; + ${THREE.ShaderChunk.fog_pars_vertex} + void main() { + vNormal = normalize(normalMatrix * normal); + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + vPositionNormal = normalize(-mvPosition.xyz); + gl_Position = projectionMatrix * mvPosition; + ${THREE.ShaderChunk.fog_vertex} + } +`; + +const ATMOSPHERE_FRAGMENT = /* glsl */ ` + uniform float c; + uniform float p; + uniform vec3 glowColor; + varying vec3 vNormal; + varying vec3 vPositionNormal; + ${THREE.ShaderChunk.fog_pars_fragment} + void main() { + float intensity = pow(max(0.0, c - dot(vNormal, vPositionNormal)), p); + vec3 glow = glowColor * intensity * 2.0; + gl_FragColor = vec4(glow, intensity * 1.5); + ${THREE.ShaderChunk.fog_fragment} + } +`; + +export function createAtmosphereMaterial(color: string): THREE.ShaderMaterial { + return new THREE.ShaderMaterial({ + uniforms: THREE.UniformsUtils.merge([ + THREE.UniformsLib['fog'], + { + c: { value: 0.3 }, + p: { value: 4.0 }, + glowColor: { value: new THREE.Color(color) }, + }, + ]), + vertexShader: ATMOSPHERE_VERTEX, + fragmentShader: ATMOSPHERE_FRAGMENT, + side: THREE.BackSide, + blending: THREE.AdditiveBlending, + transparent: true, + depthWrite: false, + fog: true, + }); +} diff --git a/src/utils/textures.ts b/src/utils/textures.ts new file mode 100644 index 0000000..0b9db62 --- /dev/null +++ b/src/utils/textures.ts @@ -0,0 +1,30 @@ +import * as THREE from 'three'; + +/** Generates a procedural canvas-based noise texture as a fallback. */ +export function generateNoiseTexture(baseColor: string): THREE.CanvasTexture { + const size = 512; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, size, size); + + const imageData = ctx.getImageData(0, 0, size, size); + const { data } = imageData; + + for (let i = 0; i < data.length; i += 4) { + const noise = (Math.random() - 0.5) * 30; + data[i] = Math.min(255, Math.max(0, data[i] + noise)); + data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + noise)); + data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + noise)); + } + + ctx.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + return texture; +}