// Three.js sphere UVs place Greenwich at u=.25; geographic textures at u=.5. export const EQUIRECTANGULAR_OFFSET=.25; const RAD=Math.PI/180; export function normaliseLongitude(lon){return lon>=-180&&lon<180?lon:((lon+180)%360+360)%360-180;} export function lonLatToVector(lon,lat,radius=1){ const a=lon*RAD,b=lat*RAD,c=Math.cos(b); return {x:radius*c*Math.sin(a),y:radius*Math.sin(b),z:radius*c*Math.cos(a)}; } export function cameraFocus(country){return {longitude:normaliseLongitude(country.lon),latitude:Math.max(-75,Math.min(75,country.lat))};} export function isMarkerVisible(lon,lat,camera,distance=3){ const p=lonLatToVector(lon,lat),c=lonLatToVector(camera.longitude,camera.latitude); return p.x*c.x+p.y*c.y+p.z*c.z>1/distance; } export function metricFraction(value,min,max){ if(value===null||value===undefined||!Number.isFinite(value))return null; if(max===min)return .5; return Math.max(0,Math.min(1,(value-min)/(max-min))); } export function makeGraticule(spacing=30,step=3,radius=1.002){ const lines=[]; for(let lon=-180;lon<180;lon+=spacing)for(let lat=-90;lat<90;lat+=step) lines.push([lonLatToVector(lon,lat,radius),lonLatToVector(lon,Math.min(90,lat+step),radius)]); for(let lat=-60;lat<=60;lat+=spacing)for(let lon=-180;lon<180;lon+=step) lines.push([lonLatToVector(lon,lat,radius),lonLatToVector(Math.min(180,lon+step),lat,radius)]); return lines; } /** Inverse of lonLatToVector for a unit-sphere hit point. */ export function vectorToLonLat(x,y,z){ const r=Math.hypot(x,y,z)||1; return {lon:normaliseLongitude(Math.atan2(x/r,z/r)/RAD),lat:Math.asin(Math.max(-1,Math.min(1,y/r)))/RAD}; } /** Even-odd point-in-ring test on [lon,lat] pairs (planar; adequate for 1:110m reference geometry). */ export function pointInRing(lon,lat,ring){ let inside=false; for(let i=0,j=ring.length-1;ilat)!==(yj>lat)&&lon<(xj-xi)*(lat-yi)/((yj-yi)||1e-12)+xi)inside=!inside; } return inside; } /** polygons: array of polygons, each an array of rings (outer first, then holes). */ export function pointInPolygons(lon,lat,polygons){ for(const rings of polygons){ if(!rings.length||!pointInRing(lon,lat,rings[0]))continue; let inHole=false;for(let k=1;k0)||!(min>0)||!(max>0))return null;const a=Math.log10(min),b=Math.log10(max);return b===a?.5:Math.max(0,Math.min(1,(Math.log10(value)-a)/(b-a)));} return metricFraction(value,min,max); } /** Sequential ramp used by the choropleth and legend: deep teal → mint → pale lime. Returns CSS hex. */ export const RAMP=['#1d4a50','#226e6c','#2f9283','#63b79a','#a4d9b4','#e3f6d2']; export function rampColor(t){ if(t===null||t===undefined)return '#2b3f44'; const n=RAMP.length-1,x=Math.max(0,Math.min(1,t))*n,i=Math.min(n-1,Math.floor(x)),f=x-i; const a=hex(RAMP[i]),b=hex(RAMP[i+1]); return '#'+[0,1,2].map(k=>Math.round(a[k]+(b[k]-a[k])*f).toString(16).padStart(2,'0')).join(''); } function hex(h){return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));}