ASCEND / globe.mjs
mmrech's picture
v2.1.0: travel-time overlay shipped as inline raster text; overlay loader; notes
d084ae4 verified
Raw History Blame Contribute Delete
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';}
};
}