ASCEND / globe-math.mjs
mmrech's picture
v2.0.0: all-economy World lens with LCoGS and SDG 3.8 indicators, choropleth globe, build pipeline and tests
c140718 verified
Raw History Blame Contribute Delete
3.42 kB
// 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;i<ring.length;j=i++){
const [xi,yi]=ring[i],[xj,yj]=ring[j];
if((yi>lat)!==(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;k<rings.length;k++)if(pointInRing(lon,lat,rings[k])){inHole=true;break;}
if(!inHole)return true;
}
return false;
}
/** Fraction in [0,1] on a linear or log10 scale; null for missing or non-positive log values. */
export function scaleFraction(value,min,max,scale='linear'){
if(value===null||value===undefined||!Number.isFinite(value))return null;
if(scale==='log'){if(!(value>0)||!(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));}