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>;
}
|