Spaces:
Running
Running
| import {hslFromString, randomWord, normalizeString} from './utils.js'; | |
| const THREE=window.THREE;const OrbitControls=THREE.OrbitControls;const TextGeometry=THREE.TextGeometry;const FontLoader=THREE.FontLoader; | |
| let scene,camera,renderer,controls,raycaster,mouse,tooltip,font,rootGroup,starsGroup,comet,cometTrail=[],cometTimer=0,cometWordTimer=0,cometWord=''; | |
| let minimapCtx,minimapScale=0.025,minimapDots=[];const MINIMAP_DOT_SIZE=2;const fontLoader=new FontLoader(); | |
| const usersCenters={}; | |
| export function getUsersCenters(){return usersCenters;} | |
| export function userCenter(uid){if(usersCenters[uid])return usersCenters[uid];let hash=0;for(let i=0;i<uid.length;i++)hash=uid.charCodeAt(i)+((hash<<5)-hash);const ang=Math.abs(hash)%360*Math.PI/180;const ring=600+(Math.abs(hash)%7)*180;const x=Math.cos(ang)*ring;const z=Math.sin(ang)*ring;const y=((hash%37)-18)*2;const v=new THREE.Vector3(x,y,z);usersCenters[uid]=v;return v;} | |
| export function spiralPosition(uid,i){const c=userCenter(uid);const a=0.55;const step=0.9;const ang=i*a;const r=6+step*i*0.45;const y=(i%18-9)*0.22;const x=Math.cos(ang)*r;const z=Math.sin(ang)*r;return new THREE.Vector3(c.x+x,c.y+y,c.z+z);} | |
| export function initScene(){scene=new THREE.Scene();camera=new THREE.PerspectiveCamera(70,window.innerWidth/window.innerHeight,0.1,4000);camera.position.set(0,8,28);renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});renderer.setSize(window.innerWidth,window.innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2));document.getElementById('container').appendChild(renderer.domElement);controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true;controls.dampingFactor=.06;controls.target.set(0,0,0);const amb=new THREE.AmbientLight(0xbfd4ff,.75);scene.add(amb);const dir=new THREE.DirectionalLight(0xffffff,.9);dir.position.set(6,10,7);scene.add(dir);const hemi=new THREE.HemisphereLight(0x4fc3f7,0x0b1020,.35);scene.add(hemi);raycaster=new THREE.Raycaster();mouse=new THREE.Vector2();rootGroup=new THREE.Group();scene.add(rootGroup);tooltip=document.getElementById('tooltip');addBackgroundStars();addComet();const minimap=document.getElementById('minimap');minimapCtx=minimap.getContext('2d');minimap.addEventListener('click',onMinimapClick);document.getElementById('zoomInButton').addEventListener('click',()=>{minimapScale*=1.4;drawMinimap([],{})});document.getElementById('zoomOutButton').addEventListener('click',()=>{minimapScale/=1.4;drawMinimap([],{})});window.addEventListener('resize',onResize);window.addEventListener('mousemove',onPointerMove);fontLoader.load('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/fonts/helvetiker_regular.typeface.json',f=>{font=f;});animate();} | |
| export function clearScene(){rootGroup.clear();} | |
| export function focusOnUser(uid){const c=userCenter(uid);controls.target.copy(c);camera.position.copy(c.clone().add(new THREE.Vector3(0,8,26)));controls.update();} | |
| export function teleportToUser(uid){const c=userCenter(uid);controls.target.copy(c);camera.position.copy(c.clone().add(new THREE.Vector3(0,8,26)));controls.update();} | |
| function animate(){requestAnimationFrame(animate);controls.update();updateRaycast();updateComet();renderer.render(scene,camera);} | |
| function onResize(){camera.aspect=window.innerWidth/window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth,window.innerHeight);} | |
| function onPointerMove(e){mouse.x=e.clientX/window.innerWidth*2-1;mouse.y=-(e.clientY/window.innerHeight)*2+1;tooltip.style.left=`${e.clientX+16}px`;tooltip.style.top=`${e.clientY}px`} | |
| function updateRaycast(){raycaster.setFromCamera(mouse,camera);const meshes=[];rootGroup.traverse(o=>{if(o.isMesh&&!o.userData.ignoreHit)meshes.push(o)});const is=raycaster.intersectObjects(meshes,false);if(is.length>0){const o=is[0].object;const d=o.userData;let html=`<strong>${d.label||d.hashtag||'Nodo'}</strong>`;if(d.level!=null)html+=`<br>Nivel: ${d.level}`;tooltip.innerHTML=html;tooltip.style.display='block';}else{tooltip.style.display='none';}} | |
| function addBackgroundStars(){starsGroup=new THREE.Group();const geo=new THREE.BufferGeometry();const count=2000;const positions=new Float32Array(count*3);for(let i=0;i<count;i++){const r=1200*Math.pow(Math.random(),.6)+200;const a=Math.random()*Math.PI*2;const e=(Math.random()-0.5)*0.6;const x=Math.cos(a)*r;const z=Math.sin(a)*r;const y=r*e*0.25;positions[i*3]=x;positions[i*3+1]=y;positions[i*3+2]=z;}geo.setAttribute('position',new THREE.BufferAttribute(positions,3));const mat=new THREE.PointsMaterial({color:0x88b4ff,sizeAttenuation:true,size:1.2,transparent:true,opacity:.8});const pts=new THREE.Points(geo,mat);starsGroup.add(pts);scene.add(starsGroup);} | |
| function addComet(){const cometGeo=new THREE.SphereGeometry(0.35,16,16);const cometMat=new THREE.MeshStandardMaterial({color:0xffe08a,emissive:0xffb703,emissiveIntensity:1.2,metalness:.2,roughness:.4});comet=new THREE.Mesh(cometGeo,cometMat);scene.add(comet);comet.userData.radius=220;comet.userData.speed=0.0022;comet.userData.angle=Math.random()*Math.PI*2;comet.userData.incl=0.25;} | |
| function updateComet(){if(!comet)return;comet.userData.angle+=comet.userData.speed;const a=comet.userData.angle;const r=comet.userData.radius;const inc=comet.userData.incl;comet.position.set(Math.cos(a)*r,Math.sin(a*inc)*25,Math.sin(a)*r);cometTimer+=1/60;cometWordTimer+=1/60;if(cometTimer>.045){cometTimer=0;leaveTrailWord();}if(cometWordTimer>3){cometWordTimer=0;cometWord=randomWord();}for(let i=cometTrail.length-1;i>=0;i--){const m=cometTrail[i];m.material.opacity-=.01;m.position.y+=.03;if(m.material.opacity<=0){m.geometry.dispose();m.material.dispose();scene.remove(m);cometTrail.splice(i,1);}}} | |
| function leaveTrailWord(){if(!font)return;const s=cometWord||randomWord();const g=new TextGeometry(s.toUpperCase(),{font,size:.9,height:.02,curveSegments:4,bevelEnabled:false});g.computeBoundingBox();const m=new THREE.MeshBasicMaterial({color:0x4ade80,transparent:true,opacity:.8});const mesh=new THREE.Mesh(g,m);mesh.position.copy(comet.position);mesh.position.y+=.6;mesh.rotation.y=Math.random()*Math.PI;cometTrail.push(mesh);scene.add(mesh);} | |
| export function usernameSphere(uid,uname){const center=userCenter(uid);const mat=new THREE.MeshPhysicalMaterial({color:0x60a5fa,emissive:0x1e293b,roughness:.25,metalness:.35,clearcoat:.6,clearcoatRoughness:.2});const geo=new THREE.SphereGeometry(2.2,32,32);const s=new THREE.Mesh(geo,mat);s.position.copy(center);s.userData={ignoreHit:false,label:uname};rootGroup.add(s);if(font){const tg=new TextGeometry(uname.toUpperCase(),{font,size:.9,height:.05,curveSegments:6,bevelEnabled:false});tg.computeBoundingBox();const tm=new THREE.MeshBasicMaterial({color:0x93c5fd,transparent:true,opacity:.9});const text=new THREE.Mesh(tg,tm);text.position.copy(center);text.position.y+=3.1;text.position.x-=(tg.boundingBox.max.x-tg.boundingBox.min.x)/2;text.userData={ignoreHit:false,label:uname};rootGroup.add(text);}} | |
| export function addNeuronMesh(uid,label,level,pos,baseColor){const mat=new THREE.MeshStandardMaterial({color:new THREE.Color(baseColor),roughness:.4,metalness:.2,emissive:new THREE.Color(baseColor).multiplyScalar(.15)});const r=level===1?.45:level===2?.28:.18;const geo=new THREE.IcosahedronGeometry(r,1);const m=new THREE.Mesh(geo,mat);m.position.copy(pos);m.userData={label,level};const rimGeo=new THREE.RingGeometry(r*1.2,r*1.35,24);const rimMat=new THREE.MeshBasicMaterial({color:0x94ffa8,transparent:true,opacity:.18,side:THREE.DoubleSide});const rim=new THREE.Mesh(rimGeo,rimMat);rim.position.copy(pos);rim.rotation.x=Math.PI/2;rim.userData={ignoreHit:true};rootGroup.add(rim);rootGroup.add(m);if(font){const tgeo=new TextGeometry(label.toUpperCase(),{font,size:r*.9,height:.02,curveSegments:4});tgeo.computeBoundingBox();const tmat=new THREE.MeshBasicMaterial({color:new THREE.Color(baseColor),transparent:true,opacity:.85});const tm=new THREE.Mesh(tgeo,tmat);tm.position.copy(pos);tm.position.y+=r+.08;tm.position.x-=(tgeo.boundingBox.max.x-tgeo.boundingBox.min.x)/2;tm.userData={label,level};rootGroup.add(tm);}} | |
| // Option B: hierarchical tree around a topic root like the original implementation | |
| export function visualizeTree(topic, lista_palabras, origin){ | |
| const acc=[]; | |
| const rootCol=hslFromString(topic).color; | |
| // Root sphere and label | |
| const rootMat=new THREE.MeshStandardMaterial({color:new THREE.Color(rootCol),roughness:.5,metalness:.1}); | |
| const rootGeo=new THREE.SphereGeometry(0.4,16,16); | |
| const root=new THREE.Mesh(rootGeo,rootMat); root.position.copy(origin); root.userData={label:topic,level:0}; rootGroup.add(root); | |
| if(font){ const tg=new TextGeometry(topic.toUpperCase(),{font,size:.3,height:.02,curveSegments:4}); tg.computeBoundingBox(); const tm=new THREE.MeshBasicMaterial({color:new THREE.Color(rootCol),transparent:true,opacity:.8}); const text=new THREE.Mesh(tg,tm); text.position.copy(origin); text.position.y+=0.5; text.position.x-=(tg.boundingBox.max.x-tg.boundingBox.min.x)/2; text.userData={label:topic,level:0,isText:true}; rootGroup.add(text);} | |
| acc.push({label:topic,level:0,position:origin.clone()}); | |
| function addBranchNode(currentTag, level, parentPos, parentColor){ | |
| const {color,h}=hslFromString(currentTag); | |
| const nodeColor=(level===1)?color:parentColor; | |
| const theta=(h/360)*Math.PI*2; | |
| let phiHash=0; for(let i=0;i<currentTag.length;i++){ phiHash=(phiHash+currentTag.charCodeAt(i)*13)%180; } | |
| const phi=((phiHash/180)*90+45)*(Math.PI/180); | |
| const baseRadius=10/(level*level); | |
| const cx=baseRadius*Math.sin(phi)*Math.cos(theta); | |
| const cy=baseRadius*Math.cos(phi); | |
| const cz=baseRadius*Math.sin(phi)*Math.sin(theta); | |
| const pos=new THREE.Vector3(cx,cy,cz).add(parentPos); | |
| const branchColor=new THREE.Color(nodeColor).multiplyScalar(0.4); | |
| const lineMat=new THREE.LineBasicMaterial({color:branchColor}); | |
| const lineGeom=new THREE.BufferGeometry().setFromPoints([parentPos,pos]); | |
| const line=new THREE.Line(lineGeom,lineMat); line.userData={ignoreHit:true}; rootGroup.add(line); | |
| const r= level===1?0.2: level===2?0.1: 0.05; | |
| const sphGeo=new THREE.SphereGeometry(r,12,12); | |
| const sphMat=new THREE.MeshStandardMaterial({color:new THREE.Color(nodeColor),roughness:.5,metalness:.1}); | |
| const sph=new THREE.Mesh(sphGeo,sphMat); sph.position.copy(pos); sph.userData={label:currentTag,level}; rootGroup.add(sph); | |
| if(font){ const tGeo=new TextGeometry(currentTag.toUpperCase(),{font,size: level===1?0.24: level===2?0.12: 0.08,height:0.02/level,curveSegments:4}); tGeo.computeBoundingBox(); const tMat=new THREE.MeshBasicMaterial({color:new THREE.Color(nodeColor),transparent:true,opacity:.8}); const tMesh=new THREE.Mesh(tGeo,tMat); tMesh.position.copy(pos); tMesh.position.y+=r+ (0.05/level); tMesh.position.x-=(tGeo.boundingBox.max.x-tGeo.boundingBox.min.x)/2; tMesh.userData={label:currentTag,level,isText:true}; rootGroup.add(tMesh);} | |
| return pos; | |
| } | |
| function walk(list, parentPos, level, parentColor){ | |
| if(!list||!list.length) return; | |
| for(const item of list){ | |
| let tag, subs; | |
| if(level===1){ tag=normalizeString(item.palabra_principal); subs=item.variantes||[]; } | |
| else if(level===2){ tag=normalizeString(item.palabra_variante); subs=item.sub_variantes||[]; } | |
| else { tag=normalizeString(item); subs=[]; } | |
| if(!tag) continue; | |
| const pos=addBranchNode(tag, level, parentPos, parentColor||hslFromString(tag).color); | |
| acc.push({label:tag,level,position:pos.clone()}); | |
| walk(subs, pos, level+1, hslFromString(tag).color); | |
| } | |
| } | |
| walk(lista_palabras, origin, 1, null); | |
| return acc; // list of nodes to be saved | |
| } | |
| export function drawMinimap(uids,profiles,me){if(!minimapCtx)return;const c=minimapCtx.canvas;minimapCtx.clearRect(0,0,c.width,c.height);minimapCtx.fillStyle='#0b1321';minimapCtx.fillRect(0,0,c.width,c.height);minimapDots=[];let cx=0,cz=0;if(me){const cc=userCenter(me);cx=cc.x;cz=cc.z;}for(const uid of uids){const center=userCenter(uid);const relX=(center.x-cx)*minimapScale;const relZ=(center.z-cz)*minimapScale;const x=c.width/2+relX;const y=c.height/2+relZ;const self=uid===me;const col=self?'#fde047':'#06b6d4';const size=MINIMAP_DOT_SIZE+(self?1:0);minimapCtx.beginPath();minimapCtx.arc(x,y,size,0,Math.PI*2);minimapCtx.fillStyle=col;minimapCtx.fill();minimapCtx.font='10px Orbitron';minimapCtx.fillStyle='#cbd5e1';const nm=(profiles[uid]?.username)||`Usr ${uid.slice(0,4)}`;minimapCtx.fillText(nm,x+size+3,y+3);minimapDots.push({x,y,uid});}} | |
| function onMinimapClick(e){const c=minimapCtx.canvas;const r=c.getBoundingClientRect();const x=e.clientX-r.left;const y=e.clientY-r.top;let pick=null,dmin=12;for(let i=minimapDots.length-1;i>=0;i--){const d=Math.hypot(x-minimapDots[i].x,y-minimapDots[i].y);if(d<dmin){dmin=d;pick=minimapDots[i].uid;}}if(pick)window.dispatchEvent(new CustomEvent('teleport',{detail:{uid:pick}}));} | |