File size: 18,644 Bytes
03677d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import {useEffect, useMemo, useRef, useState} from 'react';
import {useMotion} from './Motion';

export type AtlasPoints = {
 ids:string[]; xy:number[][]; reference:boolean[]; lengths:number[];
 clusters:number[]; colors:string[]; rotation_degrees:number; display_bounds?:number[][];
};
export type AtlasLandmark = {uniref_id:string; title:string; thumbnail:string; number:number};
type View = {zoom:number; x:number; y:number};
const gridSize=100;
const reducedMotion=()=>window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function normalize(data:AtlasPoints) {
 const angle=data.rotation_degrees*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle);
 const xy=data.xy.map(([x,y])=>[x*c-y*s,x*s+y*c]);
 let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity;
 for(const [x,y] of xy){minX=Math.min(minX,x);maxX=Math.max(maxX,x);minY=Math.min(minY,y);maxY=Math.max(maxY,y);}
 if(data.display_bounds){[[minX,minY],[maxX,maxY]]=data.display_bounds;}
 const cx=(minX+maxX)/2,cy=(minY+maxY)/2,span=Math.max(maxX-minX,maxY-minY)||1;
 const vertices=new Float32Array(data.ids.length*6),grid=new Map<string,number[]>();
 xy.forEach(([x,y],i)=>{
  const nx=(x-cx)/span*1.72,ny=(y-cy)/span*1.72,color=data.colors[data.clusters[i]];
  vertices.set([nx,ny,data.reference[i]?1:0,...[1,3,5].map(j=>parseInt(color.slice(j,j+2),16)/255)],i*6);
  const key=`${Math.floor(nx*gridSize)},${Math.floor(ny*gridSize)}`;
  const cell=grid.get(key);if(cell)cell.push(i);else grid.set(key,[i]);
 });
 return {vertices,grid};
}

export function AtlasMap({data,selectedId,onSelect,featured=[],preview=false}:{data:AtlasPoints;selectedId?:string|null;onSelect:(id:string)=>void;featured?:AtlasLandmark[];preview?:boolean}) {
 const canvas=useRef<HTMLCanvasElement>(null),host=useRef<HTMLDivElement>(null);
 const norm=useMemo(()=>normalize(data),[data]);
 const idIndex=useMemo(()=>new Map(data.ids.map((id,i)=>[id,i])),[data]);
 const [size,setSize]=useState({width:800,height:540}),[view,setView]=useState<View>({zoom:1,x:0,y:0});
 const [layer,setLayer]=useState<'communities'|'reference'|'descriptions'>(preview?'reference':'communities');
 const {enabled,reduced}=useMotion();
 const [inView,setInView]=useState(false);
 const annotation=useRef(0);
 const [hover,setHover]=useState<{index:number;x:number;y:number}|null>(null);
 const [error,setError]=useState(''),[ready,setReady]=useState(false);
 const renderer=useRef<{gl:WebGLRenderingContext;program:WebGLProgram;locations:Record<string,WebGLUniformLocation|null>}|null>(null);
 const drag=useRef<{id:number;x:number;y:number;start:View;distance:number}|null>(null);
 const pointers=useRef(new Map<number,{x:number;y:number}>());
 const pinch=useRef<{distance:number;cx:number;cy:number;view:View}|null>(null);
 const animation=useRef(0),fade=useRef(1),interacted=useRef(false);
 const liveView=useRef(view),liveSize=useRef(size),liveLayer=useRef(layer);
 liveView.current=view;liveSize.current=size;liveLayer.current=layer;
 const selectRef=useRef(onSelect);selectRef.current=onSelect;
 const aspect=size.width/size.height;
 const resetView=()=>({zoom:1.22*Math.min(1,liveSize.current.width/liveSize.current.height),x:0,y:0});
 function stopAnimation(){cancelAnimationFrame(animation.current);interacted.current=true;fade.current=1;}
 function draw(){
  const r=renderer.current,c=canvas.current;if(!r||!c)return;
  const {gl,program,locations:l}=r,s=liveSize.current,v=liveView.current;
  const dpr=Math.min(window.devicePixelRatio||1,2),w=Math.round(s.width*dpr),h=Math.round(s.height*dpr);
  if(c.width!==w||c.height!==h){c.width=w;c.height=h;}
  gl.viewport(0,0,w,h);gl.clearColor(.988,.984,.969,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.useProgram(program);
  gl.uniform2f(l.scale,v.zoom/(s.width/s.height),v.zoom);gl.uniform2f(l.pan,v.x,v.y);
  const pointSize=Math.min(6,(preview?2.35:2.1)+Math.log2(Math.max(1,v.zoom))*.7)*dpr;
  gl.uniform1f(l.pointSize,pointSize);
  gl.uniform1f(l.layer,liveLayer.current==='reference'?1:liveLayer.current==='descriptions'?2:0);
  gl.uniform1f(l.annotation,annotation.current);c.dataset.annotationProgress=annotation.current.toFixed(4);gl.uniform1f(l.alpha,fade.current);
  // A soft underlay uses the same measured points; no invented contour boundaries.
  gl.uniform1f(l.pass,0);
  if(liveLayer.current==='communities'){gl.uniform1f(l.pointSize,pointSize*2.7);gl.uniform1f(l.alpha,fade.current*.045);gl.drawArrays(gl.POINTS,0,data.ids.length);}
  gl.uniform1f(l.pointSize,pointSize);gl.uniform1f(l.alpha,fade.current);
  // Reference points draw last in the coverage view so they remain visible.
  gl.uniform1f(l.pass,0);gl.drawArrays(gl.POINTS,0,data.ids.length);
  if(liveLayer.current==='reference'){gl.uniform1f(l.pass,1);gl.drawArrays(gl.POINTS,0,data.ids.length);}
 }
 useEffect(()=>{
  const ro=new ResizeObserver(entries=>{const box=entries[0].contentRect;setSize({width:Math.max(1,box.width),height:Math.max(1,box.height)});});ro.observe(host.current!);
  return()=>ro.disconnect();
 },[]);
 useEffect(()=>{
  const c=canvas.current!,gl=c.getContext('webgl',{alpha:false,antialias:false});
  if(!gl){setError('This browser cannot display the interactive map. Search by ID or choose a featured protein below.');return;}
  let program:WebGLProgram|null=null,buffer:WebGLBuffer|null=null;const shaders:WebGLShader[]=[];
  function shader(type:number,source:string){const s=gl!.createShader(type);if(!s)throw new Error();shaders.push(s);gl!.shaderSource(s,source);gl!.compileShader(s);if(!gl!.getShaderParameter(s,gl!.COMPILE_STATUS))throw new Error(gl!.getShaderInfoLog(s)||'Shader compilation failed');return s;}
  const lost=(e:Event)=>{e.preventDefault();cancelAnimationFrame(animation.current);setError('Map graphics were interrupted. Reload to restore the map; ID search and featured proteins remain available.');};
  c.addEventListener('webglcontextlost',lost);
  try {
   program=gl.createProgram();buffer=gl.createBuffer();if(!program||!buffer)throw new Error();
   gl.attachShader(program,shader(gl.VERTEX_SHADER,`attribute vec3 point;attribute vec3 color;uniform vec2 scale;uniform vec2 pan;uniform float pointSize;uniform mediump float layer;varying mediump float reference;varying mediump vec3 tint;varying mediump float positionX;void main(){positionX=point.x;gl_Position=vec4(point.xy*scale+pan,0.,1.);gl_PointSize=pointSize+(point.z>.5?min(layer,1.)*2.:0.);reference=point.z;tint=color;}`));
   gl.attachShader(program,shader(gl.FRAGMENT_SHADER,`precision mediump float;
    varying mediump float reference;varying mediump vec3 tint;varying mediump float positionX;
    uniform mediump float layer;uniform float alpha;uniform float pass;uniform float annotation;
    void main(){
     float d=length(gl_PointCoord-vec2(.5));if(d>.5)discard;
     bool referenceView=layer>.5&&layer<1.5;
     if(referenceView&&((pass<.5&&reference>.5)||(pass>.5&&reference<.5)))discard;
     vec3 color=tint;float opacity=.67;
     if(referenceView){color=reference>.5?vec3(.85,.45,.13):vec3(.65,.69,.70);opacity=reference>.5?.96:.23;}
     if(layer>1.5){
      float reveal=smoothstep(0.,.12,annotation-(positionX*.45+.5)*.88);
      color=reference>.5?vec3(.85,.45,.13):mix(vec3(.65,.69,.70),vec3(.22,.49,.75),reveal);
      opacity=reference>.5?.96:mix(.18,.82,reveal);
     }
     gl_FragColor=vec4(color,alpha*opacity*(1.-smoothstep(.28,.5,d)));
    }`));
   gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(program)||'Map linking failed');
   gl.useProgram(program);gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,norm.vertices,gl.STATIC_DRAW);
   for(const [name,offset] of [['point',0],['color',12]] as const){const a=gl.getAttribLocation(program,name);gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,3,gl.FLOAT,false,24,offset);}
   gl.enable(gl.BLEND);gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
   renderer.current={gl,program,locations:Object.fromEntries(['scale','pan','pointSize','layer','alpha','pass','annotation'].map(n=>[n,gl.getUniformLocation(program!,n)]))};
   setReady(true);fade.current=reducedMotion()?1:0;
   const start=performance.now();const reveal=(now:number)=>{fade.current=interacted.current?1:Math.min(1,(now-start)/600);draw();if(fade.current<1)animation.current=requestAnimationFrame(reveal);};animation.current=requestAnimationFrame(reveal);
  }catch(error){console.error('Atlas renderer:',error);gl.clearColor(.988,.984,.969,1);gl.clear(gl.COLOR_BUFFER_BIT);setError('The map could not be drawn. ID search and featured proteins are still available.');}
  return()=>{cancelAnimationFrame(animation.current);c.removeEventListener('webglcontextlost',lost);renderer.current=null;if(buffer)gl.deleteBuffer(buffer);if(program)gl.deleteProgram(program);shaders.forEach(s=>gl.deleteShader(s));};
 },[norm]);
 useEffect(()=>{if(preview||(!interacted.current&&!selectedId))setView(resetView());},[size.width,size.height,preview]);
 useEffect(()=>{draw();},[view,size,layer,ready]);
 useEffect(()=>{
  if(preview||!selectedId||!ready)return;
  const index=idIndex.get(selectedId);if(index===undefined)return;
  stopAnimation();const v=liveView.current,a=liveSize.current.width/liveSize.current.height;
  const zoom=Math.max(Math.min(2.1,a*2.1),v.zoom),target={zoom,x:-norm.vertices[index*6]*zoom/a,y:-norm.vertices[index*6+1]*zoom};
  if(reducedMotion()){setView(target);return;}
  const start=performance.now();const move=(now:number)=>{const t=Math.min(1,(now-start)/480),e=1-Math.pow(1-t,3);setView({zoom:v.zoom+(target.zoom-v.zoom)*e,x:v.x+(target.x-v.x)*e,y:v.y+(target.y-v.y)*e});if(t<1)animation.current=requestAnimationFrame(move);};animation.current=requestAnimationFrame(move);
  return()=>cancelAnimationFrame(animation.current);
 },[selectedId,ready,idIndex,norm,preview]);
 useEffect(()=>{
  const observer=new IntersectionObserver(([entry])=>setInView(entry.isIntersecting));
  if(host.current)observer.observe(host.current);
  return()=>observer.disconnect();
 },[]);
 useEffect(()=>{
  if(!ready)return;
  if(layer!=='descriptions'){annotation.current=0;draw();return;}
  if(reduced){annotation.current=1;draw();return;}
  if(!enabled||!inView)return;
  let frame=0;
  const from=annotation.current,start=performance.now();
  const tick=(now:number)=>{annotation.current=Math.min(1,from+(now-start)/1400);draw();if(annotation.current<1)frame=requestAnimationFrame(tick);};
  frame=requestAnimationFrame(tick);
  return()=>cancelAnimationFrame(frame);
 },[layer,ready,enabled,reduced,inView]);
 function nearest(x:number,y:number){
  const v=liveView.current,s=liveSize.current,a=s.width/s.height,nx=((x/s.width*2-1)-v.x)*a/v.zoom,ny=((1-y/s.height*2)-v.y)/v.zoom;
  const radius=18/s.height/v.zoom;let best=-1,distance=radius*radius;
  for(let gx=Math.floor((nx-radius)*gridSize);gx<=Math.floor((nx+radius)*gridSize);gx++)for(let gy=Math.floor((ny-radius)*gridSize);gy<=Math.floor((ny+radius)*gridSize);gy++)for(const i of norm.grid.get(`${gx},${gy}`)||[]){const dx=norm.vertices[i*6]-nx,dy=norm.vertices[i*6+1]-ny,d=dx*dx+dy*dy;if(d<distance){distance=d;best=i;}}
  return best;
 }
 function zoomBy(factor:number,cx=0,cy=0){stopAnimation();setView(v=>{const z=Math.max(.25,Math.min(24,v.zoom*factor)),ratio=z/v.zoom;return {zoom:z,x:cx-(cx-v.x)*ratio,y:cy-(cy-v.y)*ratio};});setHover(null);}
 useEffect(()=>{
  const c=canvas.current!;const wheel=(e:WheelEvent)=>{if(!e.ctrlKey&&document.activeElement!==c)return;e.preventDefault();const rect=c.getBoundingClientRect();zoomBy(Math.exp(-e.deltaY*.002),(e.clientX-rect.left)/rect.width*2-1,1-(e.clientY-rect.top)/rect.height*2);};c.addEventListener('wheel',wheel,{passive:false});return()=>c.removeEventListener('wheel',wheel);
 },[]);
 const markers=useMemo(()=>{
  const ids=[...new Set([...featured.map(c=>c.uniref_id),...(selectedId?[selectedId]:[])])];
  const placed:{id:string;left:number;top:number;x:number;y:number;number:number|string;title:string}[]=[];
  for(const id of ids){
   const i=idIndex.get(id);if(i===undefined)continue;
   const x=(norm.vertices[i*6]*view.zoom/aspect+view.x+1)/2*size.width,y=(1-norm.vertices[i*6+1]*view.zoom-view.y)/2*size.height;
   if(x<0||x>size.width||y<0||y>size.height)continue;
   let left=x,top=y;
   for(let step=0;step<100;step++){
    const radius=step===0?0:22+Math.floor(step/12)*16,angle=step*2.4;
    left=Math.max(24,Math.min(size.width-24,x+Math.cos(angle)*radius));top=Math.max(24,Math.min(size.height-24,y+Math.sin(angle)*radius));
    if(placed.every(p=>Math.hypot(p.left-left,p.top-top)>42))break;
   }
   const f=featured.find(c=>c.uniref_id===id);placed.push({id,left,top,x,y,number:f?.number||'•',title:f?.title||id});
  }
  return placed;
 },[featured,selectedId,idIndex,norm,view,aspect,size]);
 const visibleMarkers=layer==='reference'?markers.filter(m=>m.id===selectedId||data.reference[idIndex.get(m.id)!]):markers;
 return <div className={'atlas-map '+(preview?'atlas-map-preview':'')} data-layer={layer}>
  <div className="atlas-map-toolbar"><div className="atlas-layers" role="group" aria-label="Map layer"><button aria-pressed={layer==='reference'} onClick={()=>{stopAnimation();setLayer('reference');}}>Swiss-Prot overlap</button><button aria-pressed={layer==='descriptions'} onClick={()=>{stopAnimation();if(layer!=='descriptions'){annotation.current=enabled?0:1;setLayer('descriptions');}}}>Ouroboros descriptions</button><button aria-pressed={layer==='communities'} onClick={()=>{stopAnimation();setLayer('communities');}}>Sequence communities</button></div><span className="micro">{data.ids.length.toLocaleString()} DISPLAYED</span></div>
  <div className="atlas-map-stage" ref={host}>
   <canvas ref={canvas} tabIndex={0} aria-label="Protein map. Drag to pan; pinch or focus and scroll to zoom. Arrow keys pan, plus and minus zoom, zero resets. Numbered buttons open featured proteins."
    onPointerDown={e=>{stopAnimation();e.currentTarget.focus({preventScroll:true});e.currentTarget.setPointerCapture(e.pointerId);pointers.current.set(e.pointerId,{x:e.clientX,y:e.clientY});if(pointers.current.size===2){const [a,b]=[...pointers.current.values()],r=e.currentTarget.getBoundingClientRect();pinch.current={distance:Math.hypot(a.x-b.x,a.y-b.y),cx:((a.x+b.x)/2-r.left)/r.width*2-1,cy:1-((a.y+b.y)/2-r.top)/r.height*2,view:liveView.current};drag.current=null;}else drag.current={id:e.pointerId,x:e.clientX,y:e.clientY,start:liveView.current,distance:0};setHover(null);}}
    onPointerMove={e=>{if(pointers.current.has(e.pointerId))pointers.current.set(e.pointerId,{x:e.clientX,y:e.clientY});const p=pinch.current;if(p&&pointers.current.size===2){const [a,b]=[...pointers.current.values()],r=e.currentTarget.getBoundingClientRect(),z=Math.max(.25,Math.min(24,p.view.zoom*Math.hypot(a.x-b.x,a.y-b.y)/Math.max(1,p.distance))),ratio=z/p.view.zoom;setView({zoom:z,x:((a.x+b.x)/2-r.left)/r.width*2-1-(p.cx-p.view.x)*ratio,y:1-((a.y+b.y)/2-r.top)/r.height*2-(p.cy-p.view.y)*ratio});return;}const d=drag.current;if(d&&d.id===e.pointerId){const dx=e.clientX-d.x,dy=e.clientY-d.y;d.distance=Math.max(d.distance,Math.hypot(dx,dy));setView({...d.start,x:d.start.x+dx/size.width*2,y:d.start.y-dy/size.height*2});return;}if(e.pointerType!=='mouse')return;const rect=e.currentTarget.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top,i=nearest(x,y);setHover(i<0?null:{index:i,x,y});}}
    onPointerUp={e=>{const d=drag.current;if(d&&d.id===e.pointerId&&d.distance<5){const r=e.currentTarget.getBoundingClientRect(),i=nearest(e.clientX-r.left,e.clientY-r.top);if(i>=0)selectRef.current(data.ids[i]);}pointers.current.delete(e.pointerId);pinch.current=null;drag.current=null;}}
    onPointerCancel={e=>{pointers.current.delete(e.pointerId);pinch.current=null;drag.current=null;}} onPointerLeave={()=>setHover(null)}
    onKeyDown={e=>{if(['+','=','-','0','ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();stopAnimation();if(e.key==='+'||e.key==='=')zoomBy(1.3);else if(e.key==='-')zoomBy(1/1.3);else if(e.key==='0')setView(resetView());else setView(v=>({...v,x:v.x+(e.key==='ArrowRight'?-.15:e.key==='ArrowLeft'?.15:0),y:v.y+(e.key==='ArrowDown'?.15:e.key==='ArrowUp'?-.15:0)}));}}}/>
   {error?<div className="atlas-map-error" role="status">{error}</div>:<>
    <div className="atlas-map-label" aria-hidden="true"><span>THE PROTEIN LANDSCAPE</span><small>{layer==='communities'?'34 sequence communities':layer==='reference'?'A small curated overlap':'Functional descriptions, at scale'}</small></div>
    <svg className="atlas-leaders" width={size.width} height={size.height} aria-hidden="true">{visibleMarkers.map(m=><g key={m.id} className={m.id===selectedId?'active':''}><line x1={m.x} y1={m.y} x2={m.left} y2={m.top}/><circle cx={m.x} cy={m.y} r="2.5"/></g>)}</svg>
    {visibleMarkers.map(m=><button key={m.id} className={'atlas-marker '+(m.id===selectedId?'selected':'')} style={{left:m.left,top:m.top}} title={`${m.number} · ${m.title} · ${m.id}`} aria-label={`Showcase ${m.number}: ${m.title}, ${m.id}`} aria-pressed={m.id===selectedId} onClick={()=>{stopAnimation();onSelect(m.id);}}>{m.number}</button>)}
    <div className="atlas-zoom" role="group" aria-label="Map controls"><button onClick={()=>zoomBy(1.4)} aria-label="Zoom in">+</button><button onClick={()=>zoomBy(1/1.4)} aria-label="Zoom out">−</button><button onClick={()=>{stopAnimation();setView(resetView());}} aria-label="Reset map">↺</button></div>
   </>}
   {hover&&!error&&<div className="atlas-hover" style={{left:Math.max(8,Math.min(hover.x+14,size.width-235)),top:Math.max(8,Math.min(hover.y+14,size.height-85))}}><strong>{data.ids[hover.index]}</strong><span>{data.lengths[hover.index]} aa · Community {data.clusters[hover.index]+1}</span><span>{data.reference[hover.index]?'Exact Swiss-Prot match':'No exact Swiss-Prot match'}</span><small>Click to explore →</small></div>}
  </div>
  <div className="atlas-map-caption"><span>{layer==='communities'?<><span className="atlas-color-key" aria-hidden="true">{['#2678b2','#d9822b','#2a9d8f','#8b5cf6'].map(c=><i key={c} style={{background:c}}/>)}</span>34 Leiden communities</>:<><i className={"atlas-dot "+(layer==='descriptions'?'description':'gray')}/> {layer==='descriptions'?'Ouroboros description beyond reference':'No exact match'} <i className="atlas-dot coral"/> Exact Swiss-Prot match</>}</span><span>Drag to pan · pinch / focus + scroll to zoom</span></div>
  <p className="atlas-map-note">ESM-2 · PCA / UMAP · sampled from a 1M-sequence projection. Display proportions are not corpus estimates. Generated descriptions are functional hypotheses; communities show embedding similarity.</p>
 </div>;
}