Download globe.mjs from mmrech/ASCEND: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/spaces/mmrech/ASCEND/resolve/main/globe.mjs
- Command line
-
hf download hf://spaces/mmrech/ASCEND/globe.mjs
-
curl -L -o globe.mjs https://huggingface.co/spaces/mmrech/ASCEND/resolve/main/globe.mjs
18.7 kB
| import * as THREE from './vendor/three-0.160.1.module.js'; | |
| import geography from './assets/world-110m.mjs'; | |
| import countryGeometry from './assets/countries-110m.mjs'; | |
| import {lonLatToVector,vectorToLonLat,normaliseLongitude,cameraFocus,isMarkerVisible,scaleFraction,rampColor,pointInPolygons,makeGraticule,EQUIRECTANGULAR_OFFSET} from './globe-math.mjs'; | |
| /** | |
| * One locally bundled WebGL globe. Countries with 1:110m geometry are drawn as a choropleth texture; | |
| * economies without geometry (small states, territories) are drawn as capital reference markers. | |
| * All values come from the caller through `getValue(country)`. | |
| */ | |
| export function createGlobe(container,{countries=[],onSelect=()=>{},onHover=()=>{},selectedCountry='BRA',getValue=()=>null,metricLabel='Observed indicator',year='',scale='linear',format=v=>String(v)}={}){ | |
| let renderer; | |
| const inert={ready:false,setSelection(){},setMetric(){},setActive(){},reset(){},rotateBy(){},zoomBy(){},setAutoRotate(){},capture(){return null;},getMarkerPositions(){return [];},dispose(){}}; | |
| try{renderer=new THREE.WebGLRenderer({antialias:true,alpha:true,preserveDrawingBuffer:true,powerPreference:'low-power'});} | |
| catch(error){ | |
| const notice=document.createElement('p');notice.className='globe-fallback';notice.setAttribute('role','status'); | |
| notice.textContent='3D rendering is unavailable in this browser. Use the country selector and World Lens chart below to explore the same observations.'; | |
| container.replaceChildren(notice);container.dataset.globeReady='false';container.dataset.renderer='unavailable';return inert; | |
| } | |
| const canvas=renderer.domElement; | |
| canvas.dataset.renderer='webgl';canvas.tabIndex=0;canvas.setAttribute('role','application'); | |
| canvas.setAttribute('aria-roledescription','interactive 3D world globe'); | |
| canvas.setAttribute('aria-label','World health-system indicators. Drag to rotate; arrow keys rotate; plus and minus zoom; Space pauses or resumes rotation. Click a country to select it. Use the country selector for keyboard access to all economies.'); | |
| Object.assign(canvas.style,{width:'100%',height:'100%',display:'block',touchAction:'pan-y',cursor:'grab',outlineOffset:'-4px'}); | |
| container.append(canvas);container.dataset.renderer='webgl'; | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));renderer.setClearColor(0x081b22,0); | |
| renderer.outputColorSpace=THREE.SRGBColorSpace; | |
| const scene=new THREE.Scene(); | |
| const camera=new THREE.PerspectiveCamera(39,1,.1,30); | |
| const world=new THREE.Group();scene.add(world); | |
| scene.add(new THREE.AmbientLight(0xc6efe6,1.45)); | |
| const light=new THREE.DirectionalLight(0xf2fff8,2.15);light.position.set(-3,3,5);scene.add(light); | |
| const rim=new THREE.DirectionalLight(0x1da6a3,.8);rim.position.set(4,-1,-4);scene.add(rim); | |
| const byId=new Map(countries.map(c=>[c.id,c])); | |
| const shapes=countryGeometry.countries.filter(g=>byId.has(g.id)); | |
| const neutralShapes=countryGeometry.countries.filter(g=>!byId.has(g.id)); | |
| const shapeIds=new Set(shapes.map(s=>s.id)); | |
| const maxTexture=renderer.capabilities?.maxTextureSize||4096; | |
| const textureCanvas=document.createElement('canvas');textureCanvas.width=Math.min(4096,maxTexture);textureCanvas.height=textureCanvas.width/2; | |
| const texture=new THREE.CanvasTexture(textureCanvas);texture.colorSpace=THREE.SRGBColorSpace;texture.wrapS=THREE.RepeatWrapping;texture.offset.x=EQUIRECTANGULAR_OFFSET;texture.anisotropy=Math.min(8,renderer.capabilities?.getMaxAnisotropy?.()||4); | |
| const earth=new THREE.Mesh(new THREE.SphereGeometry(1,96,64),new THREE.MeshPhongMaterial({map:texture,shininess:8,specular:0x2b4a50}));world.add(earth); | |
| // Optional raster overlay (e.g. travel-time surface) draped just above the choropleth; loaded lazily on request. | |
| const overlayMaterial=new THREE.MeshBasicMaterial({transparent:true,opacity:.92,depthWrite:false}); | |
| const overlay=new THREE.Mesh(new THREE.SphereGeometry(1.0015,96,64),overlayMaterial);overlay.visible=false;world.add(overlay); | |
| const overlayCache=new Map();let overlayUrl=null; | |
| const gridPositions=[]; | |
| for(const [a,b] of makeGraticule())gridPositions.push(a.x,a.y,a.z,b.x,b.y,b.z); | |
| world.add(new THREE.LineSegments(new THREE.BufferGeometry().setAttribute('position',new THREE.Float32BufferAttribute(gridPositions,3)),new THREE.LineBasicMaterial({color:0x8abfb6,transparent:true,opacity:.1,depthWrite:false}))); | |
| const outlines=[]; | |
| for(const path of geography.boundaries.coordinates)for(let i=1;i<path.length;i++){ | |
| const a=lonLatToVector(path[i-1][0],path[i-1][1],1.004),b=lonLatToVector(path[i][0],path[i][1],1.004); | |
| outlines.push(a.x,a.y,a.z,b.x,b.y,b.z); | |
| } | |
| world.add(new THREE.LineSegments(new THREE.BufferGeometry().setAttribute('position',new THREE.Float32BufferAttribute(outlines,3)),new THREE.LineBasicMaterial({color:0x0d2228,transparent:true,opacity:.55,depthWrite:false}))); | |
| const atmosphere=new THREE.Mesh(new THREE.SphereGeometry(1.025,64,48),new THREE.ShaderMaterial({ | |
| uniforms:{glowColor:{value:new THREE.Color(0x53c7bc)}}, | |
| vertexShader:'varying vec3 n; varying vec3 p; void main(){vec4 v=modelViewMatrix*vec4(position,1.0);p=v.xyz;n=normalize(normalMatrix*normal);gl_Position=projectionMatrix*v;}', | |
| fragmentShader:'varying vec3 n;varying vec3 p;uniform vec3 glowColor;void main(){float f=pow(1.0-max(0.0,dot(normalize(n),normalize(-p))),3.4);gl_FragColor=vec4(glowColor,f*0.22);}', | |
| transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.FrontSide | |
| }));world.add(atmosphere); | |
| // Capital markers only for economies without reference geometry (small states and territories). | |
| const markerGeometry=new THREE.SphereGeometry(.011,12,10); | |
| const markers=countries.filter(c=>!shapeIds.has(c.id)&&Number.isFinite(c.lat)&&Number.isFinite(c.lon)).map(country=>{ | |
| const mesh=new THREE.Mesh(markerGeometry,new THREE.MeshBasicMaterial({color:0x86dfcc})); | |
| const p=lonLatToVector(country.lon,country.lat,1.012);mesh.position.set(p.x,p.y,p.z);mesh.userData.country=country;world.add(mesh);return mesh; | |
| }); | |
| const halo=new THREE.Mesh(new THREE.RingGeometry(.03,.036,64),new THREE.MeshBasicMaterial({color:0xf1cc83,side:THREE.DoubleSide,transparent:true,opacity:.95,depthWrite:false}));world.add(halo); | |
| const haloOuter=new THREE.Mesh(new THREE.RingGeometry(.05,.053,64),new THREE.MeshBasicMaterial({color:0xf1cc83,side:THREE.DoubleSide,transparent:true,opacity:.4,depthWrite:false}));world.add(haloOuter); | |
| let selected=null, distance=3.25, longitude=-47.9292,latitude=-15.7801,target=null; | |
| let active=true,inView=true,disposed=false,autoRotate=!window.matchMedia('(prefers-reduced-motion: reduce)').matches,raf=0,lastTime=0; | |
| const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)'); | |
| let metric={getValue,metricLabel,year,scale,format}; | |
| const raycaster=new THREE.Raycaster(),pointer=new THREE.Vector2(); | |
| function syncCamera(){ | |
| const p=lonLatToVector(longitude,latitude,distance);camera.position.set(p.x,p.y,p.z);camera.lookAt(0,0,0);camera.updateMatrixWorld(); | |
| container.dataset.cameraLongitude=longitude.toFixed(3);container.dataset.cameraLatitude=latitude.toFixed(3);container.dataset.cameraDistance=distance.toFixed(3); | |
| container.dataset.autoRotate=String(autoRotate); | |
| } | |
| function draw(){if(disposed)return;syncCamera();renderer.render(scene,camera);} | |
| function canAnimate(){return active&&inView&&!document.hidden;} | |
| function frame(time){ | |
| raf=0;if(disposed||!canAnimate())return; | |
| const dt=lastTime?Math.min((time-lastTime)/1000,.05):0;lastTime=time; | |
| if(target){ | |
| const dl=normaliseLongitude(target.longitude-longitude),da=target.latitude-latitude; | |
| if(Math.abs(dl)+Math.abs(da)<.12||reducedMotion.matches){longitude=target.longitude;latitude=target.latitude;target=null;} | |
| else{const t=1-Math.exp(-dt*7);longitude=normaliseLongitude(longitude+dl*t);latitude+=da*t;} | |
| }else if(autoRotate&&!drag)longitude=normaliseLongitude(longitude+dt*2.4); | |
| draw();if(target||autoRotate)raf=requestAnimationFrame(frame); | |
| } | |
| function requestFrame(){if(!raf&&canAnimate()&&!disposed){lastTime=0;raf=requestAnimationFrame(frame);}} | |
| function pauseOrResume(){if(!canAnimate()&&raf){cancelAnimationFrame(raf);raf=0;}else requestFrame();} | |
| function paintTexture(){ | |
| const ctx=textureCanvas.getContext('2d'),W=textureCanvas.width,H=textureCanvas.height; | |
| ctx.fillStyle='#0f2a30';ctx.fillRect(0,0,W,H); | |
| const values=countries.map(c=>metric.getValue(c)).filter(v=>v!==null&&v!==undefined&&Number.isFinite(v)&&(metric.scale!=='log'||v>0)); | |
| const sorted=[...values].sort((a,b)=>a-b),q=p=>sorted.length?sorted[Math.min(sorted.length-1,Math.max(0,Math.round(p*(sorted.length-1))))]:null; | |
| // Colour scale spans the 2nd–98th percentile so a few extreme economies do not flatten the map; values outside are clamped. | |
| let min=sorted.length?q(.02):0,max=sorted.length?q(.98):1;if(min===max){min=sorted[0];max=sorted[sorted.length-1];} | |
| container.dataset.metricMin=String(min);container.dataset.metricMax=String(max);container.dataset.metricAbsMin=String(sorted[0]??'');container.dataset.metricAbsMax=String(sorted[sorted.length-1]??''); | |
| const tracePolygon=(rings,shift)=>{ctx.beginPath();for(const ring of rings){let previousX=null;ring.forEach(([lon,lat],index)=>{let x=(lon+180)/360*W;if(previousX!==null){while(x-previousX>W/2)x-=W;while(x-previousX<-W/2)x+=W;}previousX=x;const y=(90-lat)/180*H;index?ctx.lineTo(x+shift,y):ctx.moveTo(x+shift,y);});ctx.closePath();}}; | |
| ctx.lineWidth=Math.max(.8,W/2048*.9); | |
| ctx.fillStyle='#2a3d42';ctx.strokeStyle='rgba(120,150,145,.3)'; | |
| for(const shape of neutralShapes)for(const rings of shape.polygons)for(const shift of [-W,0,W]){tracePolygon(rings,shift);ctx.fill('evenodd');ctx.stroke();} | |
| for(const shape of shapes){ | |
| const t=scaleFraction(metric.getValue(byId.get(shape.id)),min,max,metric.scale); | |
| ctx.fillStyle=t===null?'#33474c':rampColor(t);ctx.strokeStyle=t===null?'rgba(120,150,145,.45)':'rgba(10,30,35,.55)'; | |
| for(const rings of shape.polygons)for(const shift of [-W,0,W]){tracePolygon(rings,shift);ctx.fill('evenodd');ctx.stroke();} | |
| } | |
| for(const mesh of markers){const t=scaleFraction(metric.getValue(mesh.userData.country),min,max,metric.scale);mesh.material.color.set(t===null?0x73858b:rampColor(t));} | |
| texture.needsUpdate=true; | |
| return {min,max,observed:values.length}; | |
| } | |
| function setSelection(id){ | |
| if(selected===id)return; | |
| const country=byId.get(id);if(!country||!Number.isFinite(country.lat)||!Number.isFinite(country.lon))return;selected=id;container.dataset.selectedCountry=id; | |
| const p=lonLatToVector(country.lon,country.lat,1.02),normal=new THREE.Vector3(p.x,p.y,p.z).normalize(); | |
| for(const ring of [halo,haloOuter]){ring.position.set(p.x,p.y,p.z);ring.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),normal);} | |
| target=cameraFocus(country);if(reducedMotion.matches){longitude=target.longitude;latitude=target.latitude;target=null;} | |
| canvas.setAttribute('aria-label',`${country.name} selected. ${metric.metricLabel}, ${metric.year}. Click a country or use the country selector to change selection. Drag or use arrow keys to rotate; plus and minus zoom; Space toggles rotation.`); | |
| draw();requestFrame(); | |
| } | |
| function setMetric(next){ | |
| metric={...metric,...next}; | |
| const stats=paintTexture(); | |
| container.dataset.metricLabel=metric.metricLabel;container.dataset.metricYear=String(metric.year);container.dataset.metricObserved=String(stats.observed); | |
| draw();return stats; | |
| } | |
| function rotateBy(deltaLon,deltaLat){target=null;longitude=normaliseLongitude(longitude+deltaLon);latitude=Math.max(-75,Math.min(75,latitude+deltaLat));draw();requestFrame();} | |
| function zoomBy(factor){distance=Math.max(1.6,Math.min(5,distance*factor));draw();requestFrame();} | |
| function setAutoRotate(value){autoRotate=Boolean(value);draw();pauseOrResume();} | |
| function reset(){distance=3.25;const country=byId.get(selected)||countries[0];target=country&&Number.isFinite(country.lon)?cameraFocus(country):{longitude:0,latitude:15};if(reducedMotion.matches){longitude=target.longitude;latitude=target.latitude;target=null;}draw();requestFrame();} | |
| function resize(){const w=Math.max(200,container.clientWidth),h=Math.max(260,container.clientHeight||460);renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();draw();} | |
| let drag=null,lastHover=0,hovered=null; | |
| function hit(event){ | |
| const rect=canvas.getBoundingClientRect();pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1); | |
| raycaster.setFromCamera(pointer,camera); | |
| const marker=raycaster.intersectObjects(markers).find(h=>isMarkerVisible(h.object.userData.country.lon,h.object.userData.country.lat,{longitude,latitude},distance)); | |
| if(marker)return marker.object.userData.country; | |
| const sphere=raycaster.intersectObject(earth)[0];if(!sphere)return null; | |
| const {lon,lat}=vectorToLonLat(sphere.point.x,sphere.point.y,sphere.point.z); | |
| const shape=shapes.find(s=>pointInPolygons(lon,lat,s.polygons)); | |
| return shape?byId.get(shape.id):null; | |
| } | |
| function down(event){if(event.button!==0&&event.pointerType!=='touch')return;drag={x:event.clientX,y:event.clientY,startX:event.clientX,startY:event.clientY,moved:false};target=null;canvas.setPointerCapture(event.pointerId);canvas.style.cursor='grabbing';} | |
| function move(event){ | |
| if(drag){const dx=event.clientX-drag.x,dy=event.clientY-drag.y;drag.moved ||= Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)>5;drag.x=event.clientX;drag.y=event.clientY;rotateBy(-dx*.28,dy*.22);} | |
| else if(performance.now()-lastHover>40){lastHover=performance.now();const country=hit(event);canvas.style.cursor=country?'pointer':'grab'; | |
| if((country?.id||null)!==(hovered?.id||null)){hovered=country;const value=country?metric.getValue(country):null;const display=value===null||value===undefined||!Number.isFinite(value)?'no observation':metric.format(value);canvas.title=country?`${country.name} — ${metric.metricLabel}: ${display}`:'';onHover(country,{value,display,x:event.clientX,y:event.clientY});} | |
| else if(country)onHover(country,{value:metric.getValue(country),display:canvas.title.split(': ').pop(),x:event.clientX,y:event.clientY,same:true});} | |
| } | |
| function up(event){if(!drag)return;const moved=drag.moved;drag=null;canvas.style.cursor='grab';if(canvas.hasPointerCapture(event.pointerId))canvas.releasePointerCapture(event.pointerId);if(!moved){const country=hit(event);if(country){setSelection(country.id);onSelect(country.id);}}requestFrame();} | |
| function cancel(){drag=null;canvas.style.cursor='grab';requestFrame();} | |
| function leave(){hovered=null;onHover(null,{});} | |
| function wheel(event){event.preventDefault();zoomBy(Math.exp(event.deltaY*.001));} | |
| function key(event){ | |
| const map={ArrowLeft:[-10,0],ArrowRight:[10,0],ArrowUp:[0,8],ArrowDown:[0,-8]}; | |
| if(map[event.key]){event.preventDefault();rotateBy(...map[event.key]);} | |
| else if(event.key==='+'||event.key==='='){event.preventDefault();zoomBy(.88);} | |
| else if(event.key==='-'){event.preventDefault();zoomBy(1.14);} | |
| else if(event.key===' '){event.preventDefault();setAutoRotate(!autoRotate);} | |
| else if(event.key==='Home'){event.preventDefault();reset();} | |
| } | |
| canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointermove',move);canvas.addEventListener('pointerup',up);canvas.addEventListener('pointercancel',cancel);canvas.addEventListener('pointerleave',leave);canvas.addEventListener('wheel',wheel,{passive:false});canvas.addEventListener('keydown',key); | |
| const observer=typeof ResizeObserver!=='undefined'?new ResizeObserver(resize):null;observer?.observe(container); | |
| const intersection=typeof IntersectionObserver!=='undefined'?new IntersectionObserver(entries=>{inView=entries[0].isIntersecting;pauseOrResume();},{threshold:.01}):null;intersection?.observe(container); | |
| document.addEventListener('visibilitychange',pauseOrResume); | |
| function motionChange(event){if(event.matches){autoRotate=false;pauseOrResume();}} | |
| reducedMotion.addEventListener?.('change',motionChange); | |
| resize();setMetric(metric);setSelection(selectedCountry); | |
| if(target){longitude=target.longitude;latitude=target.latitude;target=null;draw();} | |
| container.dataset.globeReady='true';container.dataset.shapes=String(shapes.length);container.dataset.markers=String(markers.length);requestFrame(); | |
| function setOverlay(url){ | |
| overlayUrl=url||null; | |
| if(!overlayUrl){overlay.visible=false;container.dataset.overlay='';draw();return Promise.resolve(false);} | |
| const apply=tex=>{if(overlayUrl!==url)return false;overlayMaterial.map=tex;overlayMaterial.needsUpdate=true;overlay.visible=true;container.dataset.overlay=url.startsWith('data:')?'inline-raster':url;draw();return true;}; | |
| if(overlayCache.has(url))return Promise.resolve(apply(overlayCache.get(url))); | |
| return new Promise(resolve=>{new THREE.TextureLoader().load(url,tex=>{tex.colorSpace=THREE.SRGBColorSpace;tex.wrapS=THREE.RepeatWrapping;tex.offset.x=EQUIRECTANGULAR_OFFSET;tex.anisotropy=texture.anisotropy;overlayCache.set(url,tex);resolve(apply(tex));},undefined,()=>{overlay.visible=false;container.dataset.overlay='';resolve(false);});}); | |
| } | |
| return {ready:true,setSelection,setMetric,setOverlay,setActive(value){active=Boolean(value);pauseOrResume();},reset,rotateBy,zoomBy,setAutoRotate, | |
| capture(){draw();return canvas.toDataURL('image/png');}, | |
| hasShape(id){return shapeIds.has(id);}, | |
| getMarkerPositions(){syncCamera();const rect=canvas.getBoundingClientRect();return markers.map(mesh=>{const c=mesh.userData.country,p=mesh.position.clone().project(camera);return {id:c.id,x:rect.left+(p.x+1)/2*rect.width,y:rect.top+(1-p.y)/2*rect.height,visible:isMarkerVisible(c.lon,c.lat,{longitude,latitude},distance),value:metric.getValue(c)};});}, | |
| dispose(){disposed=true;if(raf)cancelAnimationFrame(raf);observer?.disconnect();intersection?.disconnect();document.removeEventListener('visibilitychange',pauseOrResume);reducedMotion.removeEventListener?.('change',motionChange);canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointermove',move);canvas.removeEventListener('pointerup',up);canvas.removeEventListener('pointercancel',cancel);canvas.removeEventListener('pointerleave',leave);canvas.removeEventListener('wheel',wheel);canvas.removeEventListener('keydown',key);scene.traverse(o=>{o.geometry?.dispose();if(o.material){for(const material of Array.isArray(o.material)?o.material:[o.material])material.dispose();}});texture.dispose();for(const t of overlayCache.values())t.dispose();renderer.dispose();canvas.remove();container.dataset.globeReady='false';} | |
| }; | |
| } | |