ouroboros / source /src /AtlasMap.tsx
REXWind's picture
Publish the Ouroboros scientific showcase to CladeTeam
03677d0 verified
Raw History Blame Contribute Delete
18.6 kB
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>;
}