(function () {
'use strict';
const format = value => Number(Number(value).toPrecision(6)).toString();
const palette = [[68,1,84],[59,82,139],[33,145,140],[94,201,98],[253,231,37]];
window.ExoVisualization = function (container) {
const root = container;
let index = null, frameUrl = null, seriesUrl = null, frameAbort = null, seriesAbort = null;
let frameToken = 0, seriesToken = 0, frame = null, chart = null, gl = null, texture = null, program = null;
let raf = 0, observer = null, destroyed = false, mode = 'map', time = 0, level = 0, field = '', min = 0, max = 1;
let rotation = 0, pitch = 0, drag = null, bounds = null, textureDirty = true, software = false;
let composite = null, rgbFrames = null, rendering = 'field';
const raster = document.createElement('canvas');
const rasterContext = raster.getContext('2d');
root.innerHTML = `
`;
const $ = s => root.querySelector(s), map = $('.viz-map'), globe = $('.viz-globe'), chartCanvas = $('.viz-chart'), empty = $('.viz-empty'), tip = $('.viz-tooltip');
const viewSelect = $('[data-view]'), fieldSelect = $('[data-field]'), levelSelect = $('[data-level]'), timeInput = $('[data-time]');
const context = map.getContext('2d');
$('.viz-legend div').style.background='linear-gradient(90deg,'+palette.map(c=>'rgb('+c.join(',')+')').join(',')+')';
function currentField() { return (index && index.fields || []).find(x => x.name === field); }
function isComposite() { return rendering === 'rgb' && Boolean(composite); }
function showLegend() {
$('.viz-legend').hidden=isComposite();$('.viz-rgb-legend').hidden=!isComposite();
const legend=$('.viz-rgb-legend');legend.replaceChildren();
if(!isComposite())return;
composite.channels.forEach((channel,i)=>{const row=document.createElement('div'),name=document.createElement('span'),bar=document.createElement('i'),range=document.createElement('span');name.textContent=channel.label;range.textContent=`${format(channel.min)}–${format(channel.max)} ${channel.unit}`;bar.style.background=`linear-gradient(90deg,#000,${['#ff0000','#00ff00','#0000ff'][i]})`;row.append(name,bar,range);legend.append(row);});
}
function setStatus(text) { empty.textContent = text; empty.hidden = false; }
function color(v) {
if (!Number.isFinite(v)) return [0,0,0,0];
const t = max === min ? .5 : Math.max(0, Math.min(1, (v-min)/(max-min)));
const position=t*(palette.length-1),i=Math.min(palette.length-2,Math.floor(position)),fraction=position-i;
return palette[i].map((value,c)=>value+(palette[i+1][c]-value)*fraction);
}
function sizeCanvas(canvas,limit=Infinity) { const r=canvas.getBoundingClientRect(),d=Math.min(window.devicePixelRatio||1,2),scale=Math.min(1,limit/Math.max(r.width*d,r.height*d)),w=Math.max(1,Math.round(r.width*d*scale)),h=Math.max(1,Math.round(r.height*d*scale));if(canvas.width!==w)canvas.width=w;if(canvas.height!==h)canvas.height=h;return d*scale; }
function coords() { const lat=index&&index.lat||[], lon=index&&index.lon||[]; return {lat,lon}; }
function nearest(a,v,period=0) { let best=0,dist=Infinity;for(let i=0;i1?Number(lon[1])-Number(lon[0]):360;
bounds={latMin:-90,latMax:90,lonMin:Number(lon[0])-step/2,lonMax:Number(lon[0])+360-step/2,rows,cols};
raster.width=cols; raster.height=height;
const image=rasterContext.createImageData(cols,height),rgb=isComposite(),nativeColors=rgb?new Uint8ClampedArray(rows*cols*4):null;
if(rgb)for(let row=0;row1)continue;
const z=Math.sqrt(1-squared),ny=qy*cp+z*sp,nz=z*cp-qy*sp,latitude=Math.asin(Math.max(-1,Math.min(1,ny))),longitude=Math.atan2(qx,nz)+rotation,u=((longitude/(2*Math.PI)+offset)%1+1)%1,v=.5-latitude/Math.PI,si=(Math.min(raster.height-1,Math.floor(v*raster.height))*raster.width+Math.min(raster.width-1,Math.floor(u*raster.width)))*4,light=isComposite()?1:.35+.65*Math.max(0,(-.4*qx+.5*qy+z)/lightLength),alpha=source[si+3]/255;
for(let channel=0;channel<3;channel++)image.data[p+channel]=image.data[p+channel]*(1-alpha)+source[si+channel]*light*alpha;
}
context.putImageData(image,0,0);textureDirty=false;
}
function redraw(){if(mode==='map')drawMap();else drawGlobe();}
function drawChart(values) {
const c=chartCanvas.getContext('2d'),d=sizeCanvas(chartCanvas),w=chartCanvas.width,h=chartCanvas.height;
c.clearRect(0,0,w,h);if(!values?.mean)return;
const valid=values.mean.filter(v=>v!==null&&Number.isFinite(Number(v))).map(Number);
if(!valid.length)return;
const lo=Math.min(...valid),hi=Math.max(...valid),left=60*d,right=w-12*d,top=18*d,bottom=h-20*d;
const times=values.time||index.time,start=Number(times[0]),end=Number(times.at(-1));
c.fillStyle='#a8b7b9';c.font=`${10*d}px sans-serif`;
c.fillText(`Global mean ${isComposite()?'temperature ':''}${values.unit||''}`,left,12*d);
c.fillText(hi.toPrecision(4),0,top+8*d);c.fillText(lo.toPrecision(4),0,bottom);
c.fillText(`${format(start)} ${values.time_unit||''}`,left,h-3*d);
c.textAlign='right';c.fillText(format(end),right,h-3*d);c.textAlign='left';
c.strokeStyle='#65b7d8';c.lineWidth=2*d;c.beginPath();let gap=true;
values.mean.forEach((v,i)=>{if(v===null||!Number.isFinite(Number(v))){gap=true;return;}
const x=left+(Number(times[i])-start)/(end-start||1)*(right-left),y=bottom-(Number(v)-lo)/(hi-lo||1)*(bottom-top);
if(gap)c.moveTo(x,y);else c.lineTo(x,y);gap=false;
if(values.mean.length===1)c.rect(x-2*d,y-2*d,4*d,4*d);
});c.stroke();
}
async function readPlane(meta,signal) {
const response=await fetch(frameUrl(meta.name,Math.min(time,meta.shape[0]-1),isComposite()?0:level),{signal,credentials:'same-origin'});
if(!response.ok)throw Error(`Frame request failed (${response.status})`);
const raw=new Float32Array(await response.arrayBuffer()),rows=meta.shape[2],cols=meta.shape[3];
if(!rows||!cols||raw.length!==rows*cols)throw Error('Frame dimensions do not match the dataset index');
return Array.from({length:rows},(_,r)=>raw.subarray(r*cols,(r+1)*cols));
}
async function loadFrame() {
if(!index||!field||!frameUrl)return;
const token=++frameToken;if(frameAbort)frameAbort.abort();frameAbort=new AbortController();frame=null;rgbFrames=null;tip.hidden=true;
$('[data-inspector]').textContent='Loading field…';setStatus('Loading field…');
const rgb=isComposite(),metas=rgb?composite.channels.map(channel=>index.fields.find(meta=>meta.name===channel.name)):[currentField()];
try{
const planes=await Promise.all(metas.map(meta=>readPlane(meta,frameAbort.signal)));
if(token!==frameToken||destroyed)return;
frame=planes[0];if(rgb)rgbFrames=planes;textureDirty=true;
const fieldMeta=metas[0];min=fieldMeta.min==null?NaN:Number(fieldMeta.min);max=fieldMeta.max==null?NaN:Number(fieldMeta.max);
$('[data-min]').textContent=Number.isFinite(min)?min.toPrecision(4):'';$('[data-max]').textContent=Number.isFinite(max)?max.toPrecision(4):'';
empty.hidden=true;redraw();if(inspected)inspectCell(...inspected);else $('[data-inspector]').textContent='Tap or point at the field to inspect a value.';
}catch(e){if(e.name!=='AbortError'&&token===frameToken&&!destroyed){frameAbort.abort();const message=e.message||'Could not load field';setStatus(message);$('[data-inspector]').textContent=message;}}
}
async function loadSeries(){if(!index||!field||!seriesUrl)return;const token=++seriesToken;if(seriesAbort)seriesAbort.abort();seriesAbort=new AbortController();chart=null;drawChart(null);try{const response=await fetch(seriesUrl(field,level),{signal:seriesAbort.signal,credentials:'same-origin'});if(!response.ok)throw Error(`Series request failed (${response.status})`);const data=await response.json();if(token===seriesToken&&!destroyed){if(!data.ok)throw Error(data.error||'Could not load global means');chart=data.data;drawChart(chart);}}catch(e){if(e.name!=='AbortError'&&token===seriesToken)drawChart(null);} }
function changed(){
const previous=rendering;rendering=viewSelect.value;const viewChanged=previous!==rendering;
const next=isComposite()?composite.channels[0].name:fieldSelect.value,old=field,oldLevel=level;field=next;
fieldSelect.closest('label').hidden=isComposite();showLegend();
if(next!==old||!levelSelect.options.length){
levelSelect.innerHTML='';const count=currentField()?.shape[1]||1;
for(let i=0;i{mode=mode==='map'?'globe':'map';map.hidden=mode!=='map'&&!software;globe.hidden=mode!=='globe'||software;$('[data-mode]').textContent=mode==='map'?'Globe':'Map';map.setAttribute('aria-label',mode==='map'?'Spatial field map. Use arrow keys to inspect cells.':'Spatial field globe. Drag or use arrow keys to rotate.');redraw();});
let playTimer=0;const play=$('[data-play]');function stopPlayback(){if(playTimer)clearInterval(playTimer);playTimer=0;play.textContent='Play';play.setAttribute('aria-pressed','false');}play.addEventListener('click',()=>{if(playTimer){stopPlayback();return;}play.textContent='Pause';play.setAttribute('aria-pressed','true');playTimer=setInterval(()=>{if(document.hidden)return;const n=Number(timeInput.max);timeInput.value=String((Number(timeInput.value)+1)%(n+1));changed();},600);});
let inspected=null;
function inspectCell(ri,ci){if(!frame)return;inspected=[ri,ci];const value=Number(frame[ri][ci]),{lat,lon}=coords(),position=`${Number(lat[ri]).toFixed(2)}°, ${Number(lon[ci]).toFixed(2)}°`;
const text=isComposite()?position+'\n'+composite.channels.map((channel,i)=>{const v=Number(rgbFrames[i][ri][ci])*channel.factor;return`${channel.label}: ${Number.isFinite(v)?v.toPrecision(5)+' '+channel.unit:'missing'}`;}).join('\n'):`${position} · ${Number.isFinite(value)?value.toPrecision(5)+(currentField().unit?' '+currentField().unit:''):'missing'}`;tip.textContent=text;$('[data-inspector]').textContent=text;}
function inspectPointer(event,canvas,latitude,longitude){const {lat,lon}=coords();inspectCell(nearest(lat,latitude),nearest(lon,longitude,360));const r=canvas.getBoundingClientRect();tip.hidden=false;tip.style.left=Math.max(0,Math.min(r.width-tip.offsetWidth-8,event.clientX-r.left+12))+'px';tip.style.top=Math.max(0,Math.min(r.height-tip.offsetHeight-8,event.clientY-r.top+12))+'px';}
function mapPointer(event){if(mode==='globe'){globePointer(event,map);return;}if(!frame||!bounds)return;const r=map.getBoundingClientRect(),x=(event.clientX-r.left)/r.width,y=(event.clientY-r.top)/r.height;inspectPointer(event,map,90-y*180,bounds.lonMin+x*360);}
map.addEventListener('pointermove',mapPointer);
map.addEventListener('pointerdown',event=>{if(mode==='globe')startDrag(event,map);});
map.addEventListener('pointerup',event=>{drag=null;mapPointer(event);});map.addEventListener('pointercancel',()=>{drag=null;});
map.addEventListener('pointerleave',()=>{tip.hidden=true;});
map.addEventListener('keydown',event=>{if(mode==='globe'){rotateKeys(event);return;}if(!frame)return;let [ri,ci]=inspected||[Math.floor(frame.length/2),Math.floor(frame[0].length/2)];if(event.key==='ArrowUp')ri--;else if(event.key==='ArrowDown')ri++;else if(event.key==='ArrowLeft')ci--;else if(event.key==='ArrowRight')ci++;else return;event.preventDefault();inspectCell(Math.max(0,Math.min(frame.length-1,ri)),(ci+frame[0].length)%frame[0].length);});
function startDrag(event,canvas){drag={x:event.clientX,y:event.clientY,rot:rotation,pitch};canvas.setPointerCapture(event.pointerId);tip.hidden=true;}
function globePointer(event,canvas){if(drag){rotation=drag.rot+(event.clientX-drag.x)/Math.max(1,canvas.clientWidth)*Math.PI*2;pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,drag.pitch+(event.clientY-drag.y)/Math.max(1,canvas.clientHeight)*Math.PI));if(!raf)raf=requestAnimationFrame(()=>{raf=0;redraw();});return;}if(!frame||mode!=='globe')return;const r=canvas.getBoundingClientRect(),x=((event.clientX-r.left)/r.width*2-1)*Math.max(1,r.width/r.height),y=(1-(event.clientY-r.top)/r.height*2)/Math.min(1,r.width/r.height),squared=x*x+y*y;if(squared>1){tip.hidden=true;return;}const z=Math.sqrt(1-squared),ny=y*Math.cos(pitch)+z*Math.sin(pitch),nz=z*Math.cos(pitch)-y*Math.sin(pitch);inspectPointer(event,canvas,Math.asin(Math.max(-1,Math.min(1,ny)))*180/Math.PI,(Math.atan2(x,nz)+rotation)*180/Math.PI);}
globe.addEventListener('pointerdown',event=>startDrag(event,globe));globe.addEventListener('pointermove',event=>globePointer(event,globe));
globe.addEventListener('pointerup',event=>{drag=null;globePointer(event,globe);});globe.addEventListener('pointercancel',()=>{drag=null;});globe.addEventListener('pointerleave',()=>{tip.hidden=true;});
function rotateKeys(event){if(event.key==='ArrowLeft')rotation-=.1;else if(event.key==='ArrowRight')rotation+=.1;else if(event.key==='ArrowUp')pitch+=.1;else if(event.key==='ArrowDown')pitch-=.1;else if(event.key==='Home'){rotation=0;pitch=0;}else return;event.preventDefault();pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,pitch));redraw();}
globe.addEventListener('keydown',rotateKeys);
globe.addEventListener('webglcontextlost',event=>{event.preventDefault();gl=null;program=null;texture=null;textureDirty=true;software=true;redraw();});
globe.addEventListener('webglcontextrestored',()=>{software=false;textureDirty=true;redraw();});
const visibility=()=>{if(!document.hidden)redraw();};
observer=new ResizeObserver(()=>{if(!document.hidden){redraw();drawChart(chart);}});observer.observe(root);document.addEventListener('visibilitychange',visibility);
return { setDataset(next,frameFn,seriesFn,options={}){stopPlayback();inspected=null;tip.hidden=true;$('[data-inspector]').textContent='Point at the map to inspect a value.';index=next||null;composite=window.ExoComposite?.create(index,options.gravity)||null;viewSelect.querySelector('[value="rgb"]').disabled=!composite;viewSelect.closest('label').hidden=!composite;if(!composite)viewSelect.value='field';rendering=viewSelect.value;rgbFrames=null;frameUrl=frameFn;seriesUrl=seriesFn;frame=null;chart=null;textureDirty=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();frameToken++;seriesToken++;const previousField=fieldSelect.value,previousLevel=level;fieldSelect.innerHTML='';levelSelect.innerHTML='';const fields=index&&index.fields||[];for(const f of fields){const o=document.createElement('option');o.value=f.name;o.textContent=(f.label||f.name).replaceAll('_',' ')+(f.unit?' ('+f.unit+')':'');fieldSelect.appendChild(o);}field=(fields.find(f=>f.name===previousField)||fields.find(f=>f.name==='ts')||fields.find(f=>f.name==='elevation')||fields[0])?.name||'';fieldSelect.value=field;const count=currentField()?.shape[1]||0;levelSelect.closest('label').hidden=count<=1;for(let i=0;i0;setStatus(fields.length?'Loading field…':'No spatial data');if(fields.length)changed();else{drawChart(null);}}, destroy(){destroyed=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();if(observer)observer.disconnect();if(raf)cancelAnimationFrame(raf);stopPlayback();document.removeEventListener('visibilitychange',visibility);if(gl){gl.deleteTexture(texture);gl.deleteProgram(program);}root.innerHTML='';} };
};
})();