Files
exoplasim/web/js/visualization.js

177 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 = `<div class="viz-root"><div class="viz-controls"><label>View <select data-view><option value="field">Single field</option><option value="rgb">Temperature / rainfall / elevation</option></select></label><label>Field <select data-field></select></label><label>Time <input data-time type="range" min="0" max="0" value="0"></label><output data-time-label></output><label>Level <select data-level></select></label><button type="button" data-mode>Globe</button><button type="button" data-play aria-pressed="false">Play</button></div><div class="viz-view"><canvas class="viz-map" tabindex="0" aria-label="Spatial field map. Use arrow keys to inspect cells."></canvas><canvas class="viz-globe" tabindex="0" hidden aria-label="Spatial field globe. Drag or use arrow keys to rotate."></canvas><div class="viz-empty" role="status">No spatial data</div><div class="viz-tooltip" hidden></div></div><div class="viz-footer"><div class="viz-legend"><span data-min></span><div></div><span data-max></span></div><div class="viz-rgb-legend" hidden></div><output class="viz-inspector" data-inspector>Move over the field to inspect a value.</output><canvas class="viz-chart" aria-label="Global mean by time"></canvas></div></div>`;
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;i<a.length;i++){let d=Math.abs(Number(a[i])-v);if(period){d%=period;d=Math.min(d,period-d);}if(d<dist){dist=d;best=i;}}return best; }
function updateRaster() {
if (!frame || !textureDirty) return;
const rows=frame.length, cols=frame[0].length, height=Math.max(256,rows*2);
const {lat,lon}=coords(), step=lon.length>1?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;row<rows;row++)for(let x=0;x<cols;x++){
const p=(row*cols+x)*4,red=window.ExoComposite.normalise(rgbFrames[0][row][x],composite.channels[0]),green=window.ExoComposite.normalise(rgbFrames[1][row][x],composite.channels[1]),blue=window.ExoComposite.normalise(rgbFrames[2][row][x],composite.channels[2]);
nativeColors[p]=red*255;nativeColors[p+1]=green*255;nativeColors[p+2]=blue*255;nativeColors[p+3]=Number.isFinite(red)&&Number.isFinite(green)&&Number.isFinite(blue)?255:0;
}
for(let y=0;y<height;y++) {
const row=nearest(lat,90-(y+.5)/height*180);
if(rgb){image.data.set(nativeColors.subarray(row*cols*4,(row+1)*cols*4),y*cols*4);continue;}
for(let x=0;x<cols;x++) {
const c=color(Number(frame[row][x])),p=(y*cols+x)*4;
image.data[p]=c[0];image.data[p+1]=c[1];image.data[p+2]=c[2];image.data[p+3]=c.length===4?c[3]:255;
}
}
rasterContext.putImageData(image,0,0);
}
function drawMap() {
if (destroyed || !frame || mode !== 'map') return;
updateRaster(); sizeCanvas(map); context.clearRect(0,0,map.width,map.height);
context.imageSmoothingEnabled=false; context.drawImage(raster,0,0,map.width,map.height);
}
function initGL() {
if(gl)return true;
gl=globe.getContext('webgl',{alpha:false,antialias:true})||globe.getContext('experimental-webgl');
if(!gl)return false;
const vs='attribute vec2 p; varying vec2 uv; void main(){uv=(p+1.0)*0.5;gl_Position=vec4(p,0.,1.);}';
const fs='precision mediump float; varying vec2 uv; uniform sampler2D tex; uniform float aspect; uniform float rot; uniform float pitch; uniform float offset; uniform float shade; void main(){vec2 q=(uv*2.0-1.0);q.x*=max(aspect,1.);q.y/=min(aspect,1.);float r=length(q);if(r>1.){gl_FragColor=vec4(0.035,0.055,0.08,1.);return;}float z=sqrt(max(0.,1.-r*r));vec3 n=vec3(q.x,q.y*cos(pitch)+z*sin(pitch),z*cos(pitch)-q.y*sin(pitch));float lon=atan(n.x,n.z)+rot;float lat=asin(clamp(n.y,-1.,1.));vec2 t=vec2(fract(lon/6.2831853+offset),0.5-lat/3.14159265);vec4 c=texture2D(tex,t);float light=0.35+0.65*max(0.,dot(normalize(vec3(q,z)),normalize(vec3(-.4,.5,1.))));gl_FragColor=vec4(mix(vec3(.035,.055,.08),c.rgb*mix(1.,light,shade),c.a),1.);}';
function shader(type,src){const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))return null;return s;}
const a=shader(gl.VERTEX_SHADER,vs),b=shader(gl.FRAGMENT_SHADER,fs);if(!a||!b)return false;
program=gl.createProgram();gl.attachShader(program,a);gl.attachShader(program,b);gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))return false;
const buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);gl.useProgram(program);const loc=gl.getAttribLocation(program,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);texture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,texture);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);return true;
}
function drawGlobe() {
if(destroyed||mode!=='globe'||!frame)return;
if(software||!initGL()){software=true;globe.hidden=true;map.hidden=false;drawSoftwareGlobe();return;}
updateRaster(); sizeCanvas(globe,640);
gl.viewport(0,0,globe.width,globe.height);gl.bindTexture(gl.TEXTURE_2D,texture);
if(textureDirty){gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,raster.width,raster.height,0,gl.RGBA,gl.UNSIGNED_BYTE,rasterContext.getImageData(0,0,raster.width,raster.height).data);textureDirty=false;}
gl.useProgram(program);gl.uniform1f(gl.getUniformLocation(program,'shade'),isComposite()?0:1);gl.uniform1f(gl.getUniformLocation(program,'aspect'),globe.width/globe.height);gl.uniform1f(gl.getUniformLocation(program,'rot'),rotation);gl.uniform1f(gl.getUniformLocation(program,'pitch'),pitch);gl.uniform1f(gl.getUniformLocation(program,'offset'),.5/raster.width-Number(index.lon[0])/360);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
}
function drawSoftwareGlobe(){
updateRaster();sizeCanvas(map,320);
const w=map.width,h=map.height,aspect=w/h,image=context.createImageData(w,h),source=rasterContext.getImageData(0,0,raster.width,raster.height).data,cp=Math.cos(pitch),sp=Math.sin(pitch),offset=.5/raster.width-Number(index.lon[0])/360,lightLength=Math.sqrt(1.41);
for(let y=0;y<h;y++)for(let x=0;x<w;x++){
const p=(y*w+x)*4,qx=((x+.5)/w*2-1)*Math.max(aspect,1),qy=(1-(y+.5)/h*2)/Math.min(aspect,1),squared=qx*qx+qy*qy;
image.data[p]=9;image.data[p+1]=14;image.data[p+2]=20;image.data[p+3]=255;
if(squared>1)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<count;i++){const option=document.createElement('option');option.value=i;option.textContent=String(index.levels?.[i]??i);levelSelect.append(option);}
levelSelect.closest('label').hidden=count===1||isComposite();
}
level=Number(levelSelect.value)||0;time=Number(timeInput.value)||0;
const times=index?.time||[];$('[data-time-label]').textContent=times[time]==null?String(time):`${format(times[time])} ${index.time_unit||''}`;
loadFrame();if(next!==old||level!==oldLevel||viewChanged||!chart)loadSeries();
}
viewSelect.addEventListener('change',changed);fieldSelect.addEventListener('change',changed);levelSelect.addEventListener('change',changed);timeInput.addEventListener('input',changed);
$('[data-mode]').addEventListener('click',()=>{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;i<count;i++){const o=document.createElement('option');o.value=String(i);o.textContent=(index.levels&&index.levels[i]!=null)?String(index.levels[i]):String(i);levelSelect.appendChild(o);}const times=index&&index.time||[];timeInput.disabled=times.length<=1;$('[data-play]').disabled=times.length<=1;timeInput.max=String(Math.max(0,times.length-1));timeInput.value='0';time=0;level=Math.min(previousLevel,Math.max(0,count-1));levelSelect.value=String(level);empty.hidden=fields.length>0;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='';} };
};
})();