// spatial-map.jsx v5 — Herely spatial presence engine.
// Electrons: 2-layer Points — Phong-lit solid sphere + additive glow halo.
// Rings:     TorusGeometry — solid tube core + wide additive glow torus.
// Proximity: inner shells = close connections, outer = distant strangers.
// Reshuffle: every 5 min (or on demand) — bezier-arc transitions through nucleus.

const { useEffect, useRef } = React;

/* ── helpers ── */
function makeGlowTex(rgb) {
  const s=128, cv=document.createElement("canvas"); cv.width=cv.height=s;
  const ctx=cv.getContext("2d"), [r,g,b]=rgb;
  const gr=ctx.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);
  gr.addColorStop(0,   `rgba(${r},${g},${b},1)`);
  gr.addColorStop(0.28,`rgba(${r},${g},${b},0.55)`);
  gr.addColorStop(0.62,`rgba(${r},${g},${b},0.12)`);
  gr.addColorStop(1,   `rgba(${r},${g},${b},0)`);
  ctx.fillStyle=gr; ctx.fillRect(0,0,s,s);
  return new THREE.CanvasTexture(cv);
}
function hexRGB(h){const x=h.replace("#","");return[parseInt(x.slice(0,2),16),parseInt(x.slice(2,4),16),parseInt(x.slice(4,6),16)];}
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));

/* ── electron shaders ── */
// shared vertex — uSizeMult lets the glow layer use larger points
const VERT_E=`
  attribute vec3  aColor;
  attribute float aSize,aIdx,aVis,aTrans;
  uniform   float uDpr,uScale,uHov,uSel,uDimAlpha,uTime,uSizeMult;
  varying   vec3  vCol;
  varying   float vA,vH,vS,vT;
  void main(){
    vCol=aColor;
    float h=abs(aIdx-uHov)<0.5?1.0:0.0;
    float s=abs(aIdx-uSel)<0.5?1.0:0.0;
    vA=aVis>0.5?1.0:uDimAlpha; vH=h; vS=s; vT=aTrans;
    vec4 mv=modelViewMatrix*vec4(position,1.0);
    float pulse=1.0+0.28*sin(uTime*6.5)*aTrans;
    float sz=aSize*uScale*uSizeMult*(1.0+0.45*h+0.75*s+0.50*aTrans)*pulse;
    gl_PointSize=clamp(sz*uDpr*(290.0/-mv.z),2.0,110.0);
    gl_Position=projectionMatrix*mv;
  }`;

// solid Phong-lit sphere with specular highlight
const FRAG_SOLID=`
  varying vec3  vCol;
  varying float vA,vH,vS,vT;
  void main(){
    vec2  pc=gl_PointCoord-0.5; float d=length(pc);
    if(d>0.500) discard;
    float z=sqrt(max(0.0,0.250-d*d));
    vec3  n=normalize(vec3(pc*2.0,z));
    vec3  L=normalize(vec3(-0.42,-0.62,0.78));
    float diff=clamp(dot(n,L),0.0,1.0);
    vec3  R=reflect(-L,n);
    float spec=pow(max(0.0,dot(R,vec3(0,0,1))),32.0);
    float rim=smoothstep(0.44,0.26,d)*smoothstep(0.14,0.36,d)*0.28;
    vec3  col=vCol*(0.30+0.70*diff);
    col+=vec3(0.88,0.93,1.00)*spec*0.82;
    col+=vCol*rim;
    col=mix(col,vec3(1.0),vT*0.70);
    col+=vec3(0.14)*vH;
    float a=vA; if(d>0.46) a*=smoothstep(0.500,0.46,d);
    gl_FragColor=vec4(col,clamp(a,0.0,1.0));
  }`;

// soft additive glow — sits under/around each electron
const FRAG_GLOW=`
  varying vec3  vCol;
  varying float vA,vH,vS,vT;
  void main(){
    vec2  pc=gl_PointCoord-0.5; float d=length(pc);
    float g=exp(-d*5.2)*0.40+exp(-d*18.0)*0.22;
    g*=vA*(1.0+0.55*vH+1.6*vT);
    if(g<0.004) discard;
    vec3 col=mix(vCol,vec3(1.0,0.97,0.88),vT*0.52);
    gl_FragColor=vec4(col,g);
  }`;

/* ── shell table ── */
const SHELL_DEFS=[
  {r:26,  sp:0.075, e:[0.35,0.00,0.00]},
  {r:40,  sp:0.058, e:[0.00,0.00,1.20]},
  {r:55,  sp:0.044, e:[0.90,0.70,0.00]},
  {r:70,  sp:0.033, e:[0.00,1.40,0.40]},
  {r:86,  sp:0.025, e:[1.10,0.00,0.80]},
  {r:103, sp:0.018, e:[0.40,0.90,1.30]},
  {r:120, sp:0.013, e:[1.50,0.50,0.20]},
  {r:138, sp:0.009, e:[0.60,1.80,0.70]},
  {r:156, sp:0.007, e:[1.20,0.30,1.60]},
];
// tube radius per shell — inner shells are bolder
const TUBE_R=[0.62,0.57,0.52,0.47,0.43,0.39,0.36,0.33,0.30];

const RESHUFFLE_INTERVAL=300; // 5 minutes
const R_MIN=10,R_MAX=600,LOD_CLOSE=0.70,LOD_FAR=3.20,AUTO_ROT=0.010;

/* ══════════════════════════ SpatialEngine ══════════════════════════ */
class SpatialEngine {
  constructor(mount,labels,opts){
    this.mount=mount; this.labels=labels; this.opts=opts;
    this.fullscreen=false; this.filter=null;
    this.tw={motion:1,glow:1};
    this.hoverIdx=-1; this.selectedId=-1;
    this.lod="medium"; this.outerR=80;
    this.lastInteract=-99; this.time=0;
    this.dragMode=null; this.pointers=new Map();
    this.moved=0; this.pinchDist=0;
    this._shells=[]; this._rings=[];
    this._solid=null; this._glow=null;
    this._posArr=null; this._posAttr=null;
    this._transArr=null; this._transAttr=null;
    this._visAttr=null;
    this._transState=new Map();
    this._nextReshuffle=Infinity;
    this._uSolid=null; this._uGlow=null;
    this._tmp=new THREE.Vector3();
    this._initThree(); this._initStatic(); this._initLabels(); this._bind();
    this._raf=requestAnimationFrame(this._loop);
  }

  _initThree(){
    const r=this.mount.getBoundingClientRect();
    this.w=r.width||800; this.h=r.height||600;
    this.scene=new THREE.Scene();
    this.camera=new THREE.PerspectiveCamera(46,this.w/this.h,0.5,3000);
    this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:true,preserveDrawingBuffer:true,powerPreference:"high-performance"});
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
    this.renderer.setSize(this.w,this.h,false);
    this.renderer.setClearColor(0x000000,0);
    this.renderer.domElement.className="sm-canvas";
    this.mount.insertBefore(this.renderer.domElement,this.labels);
    this.target=new THREE.Vector3(); this.gTarget=new THREE.Vector3();
    this.radius=280; this.gRadius=200;
    this.theta=0.62; this.gTheta=0.62;
    this.phi=1.05; this.gPhi=1.05;
    this.velTheta=0; this.velPhi=0;
    this._proj=new THREE.Vector3();
    this._ray=new THREE.Raycaster();
    this._ray.params.Points={threshold:2};
    this._ndc=new THREE.Vector2();
  }

  _initStatic(){
    // distant starfield
    const SN=500, sp=new Float32Array(SN*3);
    for(let i=0;i<SN;i++){
      const rr=500+Math.random()*600,a=Math.random()*Math.PI*2,e=(Math.random()-.5)*Math.PI;
      sp[i*3]=Math.cos(a)*Math.cos(e)*rr; sp[i*3+1]=Math.sin(e)*rr; sp[i*3+2]=Math.sin(a)*Math.cos(e)*rr;
    }
    const sg=new THREE.BufferGeometry(); sg.setAttribute("position",new THREE.BufferAttribute(sp,3));
    this.scene.add(new THREE.Points(sg,new THREE.PointsMaterial({color:0x6080a8,size:0.9,sizeAttenuation:false,transparent:true,opacity:0.38,depthWrite:false})));

    // nucleus glows
    this._blueTex =makeGlowTex([70,130,220]);
    this._whiteTex=makeGlowTex([255,252,246]);
    const mkSp=(tex,op,sc)=>{
      const s=new THREE.Sprite(new THREE.SpriteMaterial({map:tex,transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,opacity:op}));
      s.scale.setScalar(sc); return s;
    };
    this.youOuter=mkSp(this._blueTex, 0.38,80);
    this.youMid  =mkSp(this._blueTex, 0.70,28);
    this.youCore =mkSp(this._whiteTex,0.92,11);
    this.scene.add(this.youOuter,this.youMid,this.youCore);
    // solid white bead
    this.scene.add(new THREE.Mesh(new THREE.SphereGeometry(4.0,24,20),new THREE.MeshBasicMaterial({color:0xffffff})));
    // corona torus — slowly rotates
    this.youCorona=new THREE.Mesh(
      new THREE.TorusGeometry(5.8,0.38,8,90),
      new THREE.MeshBasicMaterial({color:0xc8deff,transparent:true,opacity:0.52,side:THREE.DoubleSide,depthWrite:false})
    );
    this.scene.add(this.youCorona);

    // hover + selection glow sprites
    this.hoverSp=mkSp(this._whiteTex,0,18);
    this.selSp  =mkSp(this._whiteTex,0,22);
    this.scene.add(this.hoverSp,this.selSp);
  }

  _initLabels(){
    this.youEl=document.createElement("div"); this.youEl.className="sm-you";
    this.youEl.innerHTML='<span class="sm-you-dot"></span>You';
    this.labels.appendChild(this.youEl);
    this.hoverEl=document.createElement("div"); this.hoverEl.className="sm-pill sm-pill--hover";
    this.hoverEl.style.opacity="0"; this.labels.appendChild(this.hoverEl);
    this.namePool=[];
    for(let i=0;i<14;i++){
      const el=document.createElement("div"); el.className="sm-pill"; el.style.opacity="0";
      this.labels.appendChild(el); this.namePool.push({el,id:-1});
    }
    this._nameSet=[]; this._labelTick=0;
  }

  /* ── build room ── */
  setRoom(room){
    this.room=room;
    const N=room.people.length;
    const nShells=Math.min(9,Math.max(3,Math.ceil(N/28)));

    // shells
    this._shells=SHELL_DEFS.slice(0,nShells).map((d,si)=>({
      ...d, tubeR:TUBE_R[si],
      rotMat:new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...d.e)),
    }));
    this.outerR=this._shells[nShells-1].r;

    // remove old rings + points
    for(const r of this._rings){this.scene.remove(r);r.geometry.dispose();r.material.dispose();}
    this._rings=[];
    if(this._solid){this.scene.remove(this._solid);this._solid.geometry.dispose();this._solid.material.dispose();}
    if(this._glow) {this.scene.remove(this._glow); this._glow.material.dispose();}

    // orbital rings — LineLoop in XZ plane (matches electron position math exactly)
    // Each ring is thin, subtle — the electrons sitting on it do all the talking.
    const ringPts=[];
    for(let j=0;j<=200;j++){const a=j/200*Math.PI*2;ringPts.push(new THREE.Vector3(Math.cos(a),0,Math.sin(a)));}
    this._shells.forEach((sh,si)=>{
      const op=Math.max(0.20,0.44-si*0.024);
      const geo=new THREE.BufferGeometry().setFromPoints(
        ringPts.map(p=>new THREE.Vector3(p.x*sh.r,0,p.z*sh.r))
      );
      const mat=new THREE.LineBasicMaterial({color:0x7aaace,transparent:true,opacity:op,depthWrite:false});
      const ring=new THREE.Line(geo,mat);
      ring.rotation.set(...sh.e);
      sh.ringCore=ring; sh.ringGlow=null;
      this.scene.add(ring); this._rings.push(ring);
    });

    // proximity-based shell assignment (close=inner, distant=outer)
    this._shellOf=new Int32Array(N);
    this._baseAng=new Float32Array(N);
    this._assignShells();

    // geometry attributes
    this._posArr  =new Float32Array(N*3);
    this._transArr=new Float32Array(N);
    const colArr  =new Float32Array(N*3);
    const sizeArr =new Float32Array(N);
    const idxArr  =new Float32Array(N);
    const visArr  =new Float32Array(N).fill(1);
    room.people.forEach((p,i)=>{
      const[r,g,b]=hexRGB(p.color);
      colArr[i*3]=r/255; colArr[i*3+1]=g/255; colArr[i*3+2]=b/255;
      sizeArr[i]=p.size*9.8; idxArr[i]=i;
    });

    const geo=new THREE.BufferGeometry();
    this._posAttr  =new THREE.BufferAttribute(this._posArr,3); this._posAttr.setUsage(THREE.DynamicDrawUsage);
    this._transAttr=new THREE.BufferAttribute(this._transArr,1); this._transAttr.setUsage(THREE.DynamicDrawUsage);
    this._visAttr  =new THREE.BufferAttribute(visArr,1); this._visAttr.setUsage(THREE.DynamicDrawUsage);
    geo.setAttribute("position",this._posAttr);
    geo.setAttribute("aColor",  new THREE.BufferAttribute(colArr,3));
    geo.setAttribute("aSize",   new THREE.BufferAttribute(sizeArr,1));
    geo.setAttribute("aIdx",    new THREE.BufferAttribute(idxArr,1));
    geo.setAttribute("aVis",    this._visAttr);
    geo.setAttribute("aTrans",  this._transAttr);
    geo.boundingSphere=new THREE.Sphere(new THREE.Vector3(),this.outerR*1.5);

    const dpr=this.renderer.getPixelRatio();
    const mkU=(sizeMult)=>({
      uDpr:    {value:dpr}, uScale:{value:1}, uHov:{value:-1}, uSel:{value:-1},
      uDimAlpha:{value:0.14}, uTime:{value:0}, uSizeMult:{value:sizeMult},
    });
    this._uSolid=mkU(1.00);
    this._uGlow =mkU(1.65);

    this._solid=new THREE.Points(geo,new THREE.ShaderMaterial({
      uniforms:this._uSolid, vertexShader:VERT_E, fragmentShader:FRAG_SOLID,
      transparent:true, depthWrite:false,
    }));
    this._solid.frustumCulled=false; this.scene.add(this._solid);

    // glow layer reuses same geo (same BufferGeometry ref)
    this._glow=new THREE.Points(geo,new THREE.ShaderMaterial({
      uniforms:this._uGlow, vertexShader:VERT_E, fragmentShader:FRAG_GLOW,
      transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
    }));
    this._glow.frustumCulled=false; this.scene.add(this._glow);

    // camera
    this.gRadius=this.outerR*(this.fullscreen?3.0:2.9); this.radius=this.gRadius*1.4;
    this._transState.clear();
    this._scheduleReshuffle();
    this.setFilter(this.filter);
    this._updatePositions(0);
  }

  _assignShells(){
    // Always computes perfectly even angular spacing sorted by proximity.
    if(!this.room) return;
    const N=this.room.people.length,nS=this._shells.length;
    const perShell=Math.ceil(N/nS);
    const sorted=[...this.room.people].sort((a,b)=>a.proximity-b.proximity);
    sorted.forEach((p,idx)=>{
      const si=Math.min(nS-1,Math.floor(idx/perShell));
      const posInS=idx-si*perShell, cap=Math.min(perShell,N-si*perShell);
      this._shellOf[p.id]=si;
      this._baseAng[p.id]=(posInS/Math.max(1,cap))*Math.PI*2;
    });
  }

  _scheduleReshuffle(){ this._nextReshuffle=this.time+RESHUFFLE_INTERVAL; }

  triggerReshuffle(){ this._doReshuffle(); }

  _doReshuffle(){
    if(!this.room||!this._shells.length) return;
    const t=this.time*(this.tw?.motion||1);
    const N=this.room.people.length, nS=this._shells.length;
    const perShell=Math.ceil(N/nS);

    // Shift ~15% of proximities
    for(const p of this.room.people){
      if(Math.random()<0.15)
        p.proximity=clamp(p.proximity+(Math.random()-0.45)*0.28,0.03,0.97);
    }

    // Compute new shell assignments (proximity sort) WITHOUT touching _baseAng
    const sorted=[...this.room.people].sort((a,b)=>a.proximity-b.proximity);
    const newShellOf=new Int32Array(N);
    sorted.forEach((p,idx)=>{
      newShellOf[p.id]=Math.min(nS-1,Math.floor(idx/perShell));
    });

    // Only animate electrons that change shell — everything else is untouched
    let count=0;
    for(const p of this.room.people){
      const i=p.id;
      if(newShellOf[i]!==this._shellOf[i]){
        // Snapshot current position, then update shell (baseAng stays — keeps angular slot)
        this._transState.set(i,{
          fromX:this._posArr[i*3],
          fromY:this._posArr[i*3+1],
          fromZ:this._posArr[i*3+2],
          t:0,
          dur:1.2+Math.random()*0.8, // slow, elegant: 1.2–2.0s
        });
        this._shellOf[i]=newShellOf[i]; // new radius, same angle
        count++;
      }
    }

    this._scheduleReshuffle();
    this.opts.onReshuffle&&this.opts.onReshuffle(count);
  }

  /* ── positions: orbital math + even-spaced lerp transitions ── */
  _updatePositions(dt){
    if(!this.room||!this._posArr) return;
    const t=this.time*(this.tw?.motion||1);
    const tmp=this._tmp;

    for(let i=0;i<this.room.people.length;i++){
      const sh=this._shells[this._shellOf[i]];
      const ang=this._baseAng[i]+sh.sp*t;
      // Target: evenly-spaced position on current shell (moves with orbital speed)
      tmp.set(Math.cos(ang)*sh.r,0,Math.sin(ang)*sh.r).applyMatrix4(sh.rotMat);
      const toX=tmp.x,toY=tmp.y,toZ=tmp.z;

      const ts=this._transState.get(i);
      if(ts){
        ts.t+=dt/ts.dur;
        if(ts.t>=1){
          this._transState.delete(i);
          this._posArr[i*3]=toX; this._posArr[i*3+1]=toY; this._posArr[i*3+2]=toZ;
          this._transArr[i]=0;
        } else {
          // Ease-out cubic: fast start, soft landing
          const e=1-(1-ts.t)*(1-ts.t)*(1-ts.t);
          // Lerp from frozen snapshot to moving evenly-spaced target
          this._posArr[i*3]  =ts.fromX+(toX-ts.fromX)*e;
          this._posArr[i*3+1]=ts.fromY+(toY-ts.fromY)*e;
          this._posArr[i*3+2]=ts.fromZ+(toZ-ts.fromZ)*e;
          this._transArr[i]=Math.sin(ts.t*Math.PI);
        }
      } else {
        this._posArr[i*3]=toX; this._posArr[i*3+1]=toY; this._posArr[i*3+2]=toZ;
        this._transArr[i]=0;
      }
    }
    if(this._posAttr)   this._posAttr.needsUpdate=true;
    if(this._transAttr) this._transAttr.needsUpdate=true;
  }

  setFilter(set){
    this.filter=set;
    if(!this._visAttr||!this.room) return;
    const v=this._visAttr.array;
    for(let i=0;i<v.length;i++) v[i]=(!set||set.has(this.room.people[i].mood))?1:0;
    this._visAttr.needsUpdate=true;
    this._shells.forEach((sh,si)=>{
      const base=Math.max(0.20,0.44-si*0.024);
      if(sh.ringCore) sh.ringCore.material.opacity=set?base*0.40:base;
    });
  }

  setTweaks(tw){Object.assign(this.tw,tw);}

  setFullscreen(fs){
    this.fullscreen=fs;
    if(this.selectedId<0) this.gRadius=this.outerR*(fs?3.0:2.9);
    this._kick();
  }

  setSelected(id){
    const sel=(id==null)?-1:id;
    this.selectedId=sel;
    const v=sel;
    if(this._uSolid){this._uSolid.uHov.value=v; this._uGlow.uHov.value=v;}
    if(sel<0){
      this.gTarget.set(0,0,0);
      this.gRadius=this.outerR*(this.fullscreen?3.0:2.9);
    } else {
      this.gRadius=Math.min((this._shells[0]?.r||26)*1.8,52);
    }
    this._kick();
  }

  resetView(){
    this.gTarget.set(0,0,0);
    this.gRadius=this.outerR*(this.fullscreen?3.0:2.9);
    this.gTheta=0.62; this.gPhi=1.05; this.velTheta=this.velPhi=0; this._kick();
  }

  _kick(){ this.lastInteract=this.time; }

  /* ── camera ── */
  _updateCamera(dt){
    const focused=this.selectedId>=0;
    if(focused&&this._posArr){
      const i=this.selectedId;
      this.gTarget.set(this._posArr[i*3],this._posArr[i*3+1],this._posArr[i*3+2]);
    }
    const idle=this.time-this.lastInteract;
    if(this.dragMode==null){
      this.gTheta+=this.velTheta; this.gPhi+=this.velPhi;
      this.velTheta*=0.88; this.velPhi*=0.88;
      if(Math.abs(this.velTheta)<1e-5) this.velTheta=0;
      if(Math.abs(this.velPhi)<1e-5)   this.velPhi=0;
      if(!focused&&idle>2.0) this.gTheta+=AUTO_ROT*dt*clamp((idle-2.0)/3.0,0,1);
    }
    this.gPhi=clamp(this.gPhi,0.18,Math.PI-0.18);
    this.gRadius=clamp(this.gRadius,R_MIN,R_MAX);
    const ls=(a,b,k)=>a+(b-a)*Math.min(1,dt*k);
    this.radius=ls(this.radius,this.gRadius,5.5);
    this.theta =ls(this.theta, this.gTheta, 8);
    this.phi   =ls(this.phi,   this.gPhi,   8);
    this.target.lerp(this.gTarget,Math.min(1,dt*5));
    const sp=Math.sin(this.phi),cp=Math.cos(this.phi);
    this.camera.position.set(
      this.target.x+this.radius*sp*Math.sin(this.theta),
      this.target.y+this.radius*cp,
      this.target.z+this.radius*sp*Math.cos(this.theta));
    this.camera.lookAt(this.target);
  }

  _updateLOD(){
    let lod="medium";
    if     (this.selectedId>=0)                   lod="focus";
    else if(this.radius<this.outerR*LOD_CLOSE)    lod="close";
    else if(this.radius>this.outerR*LOD_FAR)      lod="far";
    if(lod!==this.lod){this.lod=lod; this.opts.onLOD&&this.opts.onLOD(lod);}
  }

  /* ── labels ── */
  _project(x,y,z,out){
    this._proj.set(x,y,z).project(this.camera);
    out.x=(this._proj.x*0.5+0.5)*this.w;
    out.y=(-this._proj.y*0.5+0.5)*this.h;
    out.vis=this._proj.z<1; return out;
  }

  _updateLabels(){
    const o=this._o||(this._o={});
    this._project(0,0,0,o);
    this.youEl.style.transform=`translate(-50%,-155%) translate(${o.x}px,${o.y}px)`;
    this.youEl.style.opacity=o.vis?"1":"0";

    const showNames=this.lod==="close"||this.lod==="focus";
    if(showNames&&this.room){
      if((this._labelTick++%6)===0) this._pickNames();
      let k=0;
      for(;k<this._nameSet.length;k++){
        const id=this._nameSet[k],slot=this.namePool[k];
        this._project(this._posArr[id*3],this._posArr[id*3+1],this._posArr[id*3+2],o);
        if(!o.vis||id===this.selectedId){slot.el.style.opacity="0";continue;}
        if(slot.id!==id){this._fillPill(slot.el,this.room.people[id],false);slot.id=id;}
        slot.el.style.opacity=id===this.hoverIdx?"0":"0.92";
        slot.el.style.transform=`translate(-50%,-50%) translate(${o.x}px,${o.y}px)`;
      }
      for(;k<this.namePool.length;k++) this.namePool[k].el.style.opacity="0";
    } else {
      for(const s of this.namePool) s.el.style.opacity="0";
    }

    if(this.hoverIdx>=0&&this.hoverIdx!==this.selectedId&&this.room){
      const id=this.hoverIdx;
      this._project(this._posArr[id*3],this._posArr[id*3+1],this._posArr[id*3+2],o);
      if(o.vis){
        if(this._hovPillId!==id){this._fillPill(this.hoverEl,this.room.people[id],true);this._hovPillId=id;}
        this.hoverEl.style.opacity="1";
        this.hoverEl.style.transform=`translate(-50%,-50%) translate(${o.x}px,${o.y}px)`;
      } else this.hoverEl.style.opacity="0";
    } else {this.hoverEl.style.opacity="0"; this._hovPillId=-1;}
  }

  _pickNames(){
    const cam=this.camera.position,cand=[];
    const N=this.room.people.length,step=N>400?Math.ceil(N/400):1;
    for(let i=0;i<N;i+=step){
      if(this._visAttr&&this._visAttr.array[i]<0.5) continue;
      const dx=this._posArr[i*3]-cam.x,dy=this._posArr[i*3+1]-cam.y,dz=this._posArr[i*3+2]-cam.z;
      cand.push([dx*dx+dy*dy+dz*dz,i]);
    }
    cand.sort((a,b)=>a[0]-b[0]);
    this._nameSet=cand.slice(0,this.namePool.length).map(c=>c[1]);
  }

  _fillPill(el,p,hover){
    el.innerHTML=
      `<span class="sm-pill-dot" style="background:${p.color};box-shadow:0 0 0 3px ${p.color}33,0 0 12px ${p.color}88"></span>`+
      `<span class="sm-pill-name">${p.name}</span>`+
      (hover?`<span class="sm-pill-status">· ${p.status}</span>`:"");
  }

  /* ── pointer events ── */
  _bind(){
    const el=this.renderer.domElement; el.style.cursor="grab";
    this._onDown=e=>{
      el.setPointerCapture&&el.setPointerCapture(e.pointerId);
      this.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
      this.moved=0; this._kick();
      if(this.pointers.size===1){this.dragMode=(e.button===2||e.shiftKey)?"pan":"orbit";this.velTheta=this.velPhi=0;}
      else if(this.pointers.size===2){this.dragMode="pinch";const ps=[...this.pointers.values()];this.pinchDist=Math.hypot(ps[0].x-ps[1].x,ps[0].y-ps[1].y);}
    };
    this._onMove=e=>{
      const rect=el.getBoundingClientRect();
      this._lastPt={x:e.clientX-rect.left,y:e.clientY-rect.top};
      const prev=this.pointers.get(e.pointerId);
      if(!prev){this._needPick=true;return;}
      const dx=e.clientX-prev.x,dy=e.clientY-prev.y;
      prev.x=e.clientX; prev.y=e.clientY;
      this.moved+=Math.abs(dx)+Math.abs(dy); this._kick();
      if(this.dragMode==="orbit"&&this.pointers.size===1){
        const dT=-dx*0.005,dP=-dy*0.005;
        this.gTheta+=dT; this.gPhi+=dP; this.velTheta=dT*0.55; this.velPhi=dP*0.55;
      } else if(this.dragMode==="pan"&&this.pointers.size===1){this._pan(dx,dy);}
      else if(this.dragMode==="pinch"&&this.pointers.size===2){
        const ps=[...this.pointers.values()];
        const d=Math.hypot(ps[0].x-ps[1].x,ps[0].y-ps[1].y);
        if(this.pinchDist) this.gRadius*=clamp(this.pinchDist/d,0.5,2);
        this.pinchDist=d; this._pan(dx*0.5,dy*0.5);
      }
    };
    this._onUp=e=>{
      const wasTap=this.moved<6&&this.pointers.size===1;
      this.pointers.delete(e.pointerId);
      if(this.pointers.size<2) this.pinchDist=0;
      this.dragMode=this.pointers.size>=1?"orbit":null;
      if(wasTap){this._needPick=true;this._pickNow();this.opts.onSelect&&this.opts.onSelect(this.hoverIdx>=0?this.hoverIdx:null);}
    };
    this._onWheel=e=>{e.preventDefault();this.gRadius*=1+clamp(e.deltaY,-120,120)*0.0014;this._kick();};
    this._onLeave=()=>{
      this._lastPt=null;
      if(this.hoverIdx>=0){
        this.hoverIdx=-1;
        if(this._uSolid){this._uSolid.uHov.value=-1;this._uGlow.uHov.value=-1;}
        this.hoverSp.material.opacity=0;
        this.renderer.domElement.style.cursor="grab";
        this.opts.onHover&&this.opts.onHover(null);
      }
    };
    el.addEventListener("pointerdown",this._onDown);
    window.addEventListener("pointermove",this._onMove);
    window.addEventListener("pointerup",  this._onUp);
    el.addEventListener("wheel",      this._onWheel,{passive:false});
    el.addEventListener("pointerleave",this._onLeave);
    el.addEventListener("contextmenu", e=>e.preventDefault());
    this._ro=new ResizeObserver(()=>this._resize()); this._ro.observe(this.mount);
  }

  _pan(dx,dy){
    const v=new THREE.Vector3(),right=new THREE.Vector3(),up=new THREE.Vector3();
    this.camera.getWorldDirection(v);
    right.crossVectors(v,this.camera.up).normalize();
    up.crossVectors(right,v).normalize();
    const k=this.radius*0.0014;
    this.gTarget.addScaledVector(right,-dx*k);
    this.gTarget.addScaledVector(up,dy*k);
    const max=this.outerR*1.2;
    if(this.gTarget.length()>max) this.gTarget.setLength(max);
  }

  _pickNow(){
    if(!this._lastPt||!this._solid) return;
    this._ndc.set((this._lastPt.x/this.w)*2-1,-(this._lastPt.y/this.h)*2+1);
    this._ray.setFromCamera(this._ndc,this.camera);
    this._ray.params.Points.threshold=clamp(this.radius*0.013,0.5,4);
    const hits=this._ray.intersectObject(this._solid);
    let best=-1,bestD=Infinity;
    for(const h of hits){
      if(this._visAttr&&this._visAttr.array[h.index]<0.5) continue;
      if(h.distanceToRay<bestD){bestD=h.distanceToRay;best=h.index;}
    }
    if(best!==this.hoverIdx){
      this.hoverIdx=best;
      if(this._uSolid){this._uSolid.uHov.value=best;this._uGlow.uHov.value=best;}
      this.renderer.domElement.style.cursor=best>=0?"pointer":"grab";
      this.opts.onHover&&this.opts.onHover(best);
    }
  }

  _resize(){
    const r=this.mount.getBoundingClientRect();
    if(!r.width||!r.height) return;
    this.w=r.width; this.h=r.height;
    this.camera.aspect=this.w/this.h; this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.w,this.h,false);
  }

  _loop=now=>{
    try{
      const dt=Math.min(0.05,(now-(this._last||now))/1000);
      this._last=now; this.time+=dt;

      // Scheduled reshuffle
      if(this.room&&this.time>=this._nextReshuffle) this._doReshuffle();

      this._updatePositions(dt);
      if(this._needPick&&this.dragMode==null){this._pickNow();this._needPick=false;}
      this._updateCamera(dt);
      this._updateLOD();

      // update uniforms
      if(this._uSolid){
        const zT=clamp((this.radius-R_MIN)/(R_MAX-R_MIN),0,1);
        const sc=(this.fullscreen?1.06:0.96)*(0.83+0.36*(1-zT))*this.tw.glow;
        const dA=this.filter?0.09:0.14;
        for(const u of [this._uSolid,this._uGlow]){
          u.uTime.value=this.time; u.uScale.value=sc; u.uDimAlpha.value=dA;
        }
      }

      // nucleus pulse
      const pulse=0.5+0.5*Math.sin(this.time*0.82);
      if(this.youOuter){this.youOuter.scale.setScalar(66+pulse*16);this.youOuter.material.opacity=(0.30+pulse*0.16)*this.tw.glow;}
      if(this.youMid)  {this.youMid.scale.setScalar(24+pulse*6);  this.youMid.material.opacity  =(0.58+pulse*0.20)*this.tw.glow;}
      if(this.youCorona){
        this.youCorona.rotation.y=this.time*0.14;
        this.youCorona.rotation.x=this.time*0.09;
        this.youCorona.material.opacity=(0.42+pulse*0.16)*this.tw.glow;
      }

      // hover sprite
      if(this.hoverIdx>=0&&this._posArr&&this.room){
        const id=this.hoverIdx;
        this.hoverSp.position.set(this._posArr[id*3],this._posArr[id*3+1],this._posArr[id*3+2]);
        const[r,g,b]=hexRGB(this.room.people[id].color);
        this.hoverSp.material.color.setRGB(r/255,g/255,b/255);
        this.hoverSp.material.opacity=(0.52+pulse*0.18)*this.tw.glow;
        this.hoverSp.scale.setScalar(14+pulse*4);
      } else if(this.hoverSp) this.hoverSp.material.opacity=0;

      // selection sprite
      if(this.selectedId>=0&&this._posArr&&this.room){
        const id=this.selectedId;
        this.selSp.position.set(this._posArr[id*3],this._posArr[id*3+1],this._posArr[id*3+2]);
        const[r,g,b]=hexRGB(this.room.people[id].color);
        this.selSp.material.color.setRGB(r/255,g/255,b/255);
        this.selSp.material.opacity=(0.70+pulse*0.24)*this.tw.glow;
        this.selSp.scale.setScalar(18+pulse*5);
      } else if(this.selSp) this.selSp.material.opacity=0;

      this.renderer.render(this.scene,this.camera);
      this._updateLabels();
    } catch(err){
      if(!this._errLogged){console.error("engine:",err?.stack||err);this._errLogged=true;}
    }
    this._raf=requestAnimationFrame(this._loop);
  };

  dispose(){
    cancelAnimationFrame(this._raf); this._ro&&this._ro.disconnect();
    const el=this.renderer.domElement;
    el.removeEventListener("pointerdown",this._onDown);
    window.removeEventListener("pointermove",this._onMove);
    window.removeEventListener("pointerup",  this._onUp);
    el.removeEventListener("wheel",this._onWheel);
    this.renderer.dispose(); el.remove();
  }
}

/* ── React wrapper ── */
function SpatialMap({room,filter,fullscreen,tweaks,selectedId,onHover,onSelect,onLOD,onReshuffle,apiRef}){
  const mountRef=useRef(null),labelsRef=useRef(null);
  const engRef=useRef(null),cbRef=useRef({});
  cbRef.current={onHover,onSelect,onLOD,onReshuffle};

  useEffect(()=>{
    const eng=new SpatialEngine(mountRef.current,labelsRef.current,{
      onHover: i=>cbRef.current.onHover&&cbRef.current.onHover(i),
      onSelect:i=>cbRef.current.onSelect&&cbRef.current.onSelect(i),
      onLOD:   l=>cbRef.current.onLOD&&cbRef.current.onLOD(l),
      onReshuffle:n=>cbRef.current.onReshuffle&&cbRef.current.onReshuffle(n),
    });
    engRef.current=eng;
    if(apiRef) apiRef.current={
      resetView:()=>eng.resetView(),
      focus:id=>eng.setSelected(id),
      triggerReshuffle:()=>eng.triggerReshuffle(),
    };
    return()=>eng.dispose();
  },[]);

  useEffect(()=>{if(engRef.current&&room) engRef.current.setRoom(room);},[room]);
  useEffect(()=>{engRef.current&&engRef.current.setFilter(filter);},[filter]);
  useEffect(()=>{engRef.current&&engRef.current.setFullscreen(fullscreen);},[fullscreen]);
  useEffect(()=>{engRef.current&&engRef.current.setTweaks(tweaks);},[tweaks]);
  useEffect(()=>{engRef.current&&engRef.current.setSelected(selectedId);},[selectedId]);

  return(<div className="sm-root" ref={mountRef}><div className="sm-labels" ref={labelsRef}></div></div>);
}

Object.assign(window,{SpatialMap});
