Spaces:
Paused
Paused
| import React, { useEffect, useRef, useState } from 'react' | |
| import * as THREE from 'three' | |
| import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' | |
| export default function Scene(): JSX.Element { | |
| const mountRef = useRef<HTMLDivElement|null>(null) | |
| const [ready,setReady] = useState(false) | |
| useEffect(()=>{ | |
| const container = mountRef.current! | |
| const scene = new THREE.Scene() | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000) | |
| camera.position.set(0,0,20) | |
| const renderer = new THREE.WebGLRenderer({ antialias:true }) | |
| renderer.setSize(window.innerWidth, window.innerHeight) | |
| renderer.setPixelRatio(window.devicePixelRatio) | |
| container.appendChild(renderer.domElement) | |
| const controls = new OrbitControls(camera, renderer.domElement) | |
| controls.enableDamping = true | |
| const ambient = new THREE.AmbientLight(0xffffff,0.7); scene.add(ambient) | |
| const dir = new THREE.DirectionalLight(0xffffff,0.6); dir.position.set(5,10,7.5); scene.add(dir) | |
| const group = new THREE.Group(); scene.add(group) | |
| // comet particles system | |
| const trails: {mesh:THREE.Mesh, life:number}[] = [] | |
| function addComet(word:string){ | |
| const geom = new THREE.SphereGeometry(0.12,8,8) | |
| const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(`hsl(${Math.random()*360},80%,60%)`), emissive:0x111111 }) | |
| const m = new THREE.Mesh(geom, mat) | |
| m.position.set((Math.random()-0.5)*30, (Math.random()-0.5)*20, (Math.random()-0.5)*30) | |
| (m.userData as any).vel = new THREE.Vector3((Math.random()-0.5)*0.6, (Math.random()-0.5)*0.6, (Math.random()-0.5)*0.6) | |
| (m.userData as any).word = word | |
| group.add(m) | |
| trails.push({mesh:m, life:120}) | |
| } | |
| // text labels as sprites | |
| const spriteMaterial = new THREE.SpriteMaterial({ color: 0xffffff }) | |
| function addLabel(text:string, pos:THREE.Vector3){ | |
| const canvas = document.createElement('canvas') | |
| canvas.width = 256; canvas.height = 64 | |
| const ctx = canvas.getContext('2d')! | |
| ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = '30px sans-serif' | |
| ctx.fillText(text, 8,40) | |
| const tex = new THREE.CanvasTexture(canvas) | |
| const mat = new THREE.SpriteMaterial({ map:tex, transparent:true }) | |
| const sp = new THREE.Sprite(mat) | |
| sp.scale.set(4,1,1); sp.position.copy(pos) | |
| (sp.userData as any).isLabel = true | |
| group.add(sp) | |
| } | |
| // generate initial galaxy nodes from demo algorithm | |
| function spawnGalaxy(topic:string, origin:THREE.Vector3){ | |
| const mainCount = Math.max(3, Math.min(12, Math.floor(Math.random()*10))) | |
| for(let i=0;i<mainCount;i++){ | |
| const theta = Math.random()*Math.PI*2 | |
| const r = 6 + Math.random()*6 | |
| const x = origin.x + Math.cos(theta)*r | |
| const y = origin.y + (Math.random()-0.5)*4 | |
| const z = origin.z + Math.sin(theta)*r | |
| const node = new THREE.Mesh(new THREE.SphereGeometry(0.25,10,10), new THREE.MeshStandardMaterial({ color: new THREE.Color(`hsl(${(i/mainCount)*360},80%,60%)`) })) | |
| node.position.set(x,y,z) | |
| group.add(node) | |
| addLabel('n'+i, node.position.clone().add(new THREE.Vector3(0.6,0.6,0))) | |
| // create comets passing near node | |
| if(Math.random()<0.6){ | |
| addComet('⭐') | |
| } | |
| } | |
| } | |
| // add a root | |
| const root = new THREE.Mesh(new THREE.SphereGeometry(0.5,16,16), new THREE.MeshStandardMaterial({ color: 0x66ffcc })) | |
| root.position.set(0,0,0); group.add(root); addLabel('ROOT', root.position.clone().add(new THREE.Vector3(0,1,0))) | |
| spawnGalaxy('example', new THREE.Vector3(0,0,0)) | |
| // comet update and trail rendering (simple) | |
| const trailGroup = new THREE.Group(); scene.add(trailGroup) | |
| function animate(){ | |
| requestAnimationFrame(animate) | |
| controls.update() | |
| // update comets | |
| for(let i=trails.length-1;i>=0;i--){ | |
| const t = trails[i] | |
| t.mesh.position.add((t.mesh.userData as any).vel) | |
| t.life -= 1 | |
| // spawn small fading dots to simulate trail | |
| const dot = new THREE.Mesh(new THREE.SphereGeometry(0.04,6,6), new THREE.MeshBasicMaterial({ color: (t.mesh.material as any).color, transparent:true, opacity:0.6 })) | |
| dot.position.copy(t.mesh.position) | |
| trailGroup.add(dot) | |
| // fade dots | |
| setTimeout(()=>{ try{ trailGroup.remove(dot); dot.geometry.dispose(); (dot.material as any).dispose() }catch(e){} }, 900) | |
| if(t.life<=0){ | |
| try{ group.remove(t.mesh); t.mesh.geometry.dispose(); (t.mesh.material as any).dispose() }catch(e){} | |
| trails.splice(i,1) | |
| } | |
| } | |
| renderer.render(scene, camera) | |
| } | |
| animate() | |
| const onResize = ()=>{ camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight) } | |
| window.addEventListener('resize', onResize) | |
| setReady(true) | |
| return ()=>{ | |
| window.removeEventListener('resize', onResize) | |
| try{ container.removeChild(renderer.domElement) }catch(e){} | |
| } | |
| },[]) | |
| return <div style={{width:'100vw',height:'100vh',position:'relative'}} ref={mountRef}> | |
| <div style={{position:'absolute',left:20,top:20,zIndex:3,background:'rgba(10,10,10,0.6)',padding:12,borderRadius:8}}> | |
| <input id="topic" placeholder="hashtag or topic" style={{width:300}} /> | |
| </div> | |
| </div> | |
| } | |