Files

85 lines
26 KiB
JavaScript

(function startWorkbench() {
'use strict';
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',startWorkbench,{once:true});return;}
const boot=window.EXOPLASIM||{},api=boot.api||{},$=(s,r=document)=>r.querySelector(s);
const state={schema:null,experiments:[],jobs:[],experiment:null,draftName:'',spec:null,dirty:false,job:null,logOffset:0,logTimer:null,pollTimer:null,visual:null,saveVersion:0,editVersion:0,saving:false,deleting:false,selection:0,pollBusy:false,resultLoaded:'',results:null,mapEditor:null,mapDirty:false,invalidFields:new Map()};
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const notice=(t,error=false)=>{const e=$('#workbench-notice');e.textContent=t;e.classList.toggle('error-text',error);e.classList.add('visible');clearTimeout(notice.timer);notice.timer=setTimeout(()=>e.classList.remove('visible'),4500);};
const endpoint=n=>api[n]||'';
function apiUrl(name,values={}){const q=Object.entries(values).filter(([,v])=>v!==undefined&&v!==null&&v!=='').map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&');return endpoint(name)+(q?'&'+q:'');}
async function request(name,o={}){const u=apiUrl(name,o.query);const h=new Headers(o.headers||{});if(o.body!==undefined){h.set('Content-Type','application/json');}const r=await fetch(u,{method:o.method||'GET',headers:h,body:o.body===undefined?undefined:JSON.stringify(o.body),signal:o.signal,credentials:'same-origin'});if(['frame','map'].includes(name)&&r.ok)return r.arrayBuffer();if(name==='download')return r;const p=await r.json().catch(()=>({ok:false,error:`Request failed (${r.status})`}));if(!r.ok||!p.ok){const e=new Error(p.error||`Request failed (${r.status})`);e.errors=p.errors||[];e.status=r.status;throw e;}return p.data;}
function setDirty(v=true){if(v)state.editVersion++;state.dirty=v;$('#save-state').textContent=v?'Unsaved changes':'Saved';$('#save-state').classList.toggle('dirty',v);}
function freshSpec(){const s=state.schema||{};return{model:structuredClone(s.model_defaults||{}),parameters:{},run:structuredClone(s.run_defaults||{})};}
function setExperiment(e){state.experiment=e;state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};state.invalidFields.clear();setDirty(false);renderAll();}
function renderExperiments(){const t=$('#experiment-search').value.toLowerCase();$('#experiment-list').innerHTML=state.experiments.map(e=>`<li class="experiment-item" ${e.name.toLowerCase().includes(t)?'':'hidden'}><button class="experiment-select" type="button" data-experiment="${esc(e.id)}" aria-current="${state.experiment?.id===e.id}">${esc(e.name)}<small>${esc((e.updated_at||'').slice(0,10))}</small></button></li>`).join('');}
function renderConstructor(){const m=state.spec.model,r=state.spec.run,s=$('#model-resolution');s.innerHTML='';const choices=state.schema.model?.resolution?.choices||state.schema.model_metadata?.resolution?.choices||Object.keys(state.schema.model_metadata||{});for(const c of choices){const o=document.createElement('option');o.value=c;o.textContent=c;s.append(o);}s.value=m.resolution||'';$('#model-layers').value=m.layers??'';$('#model-ncpus').value=m.ncpus??'';$('#run-cycles').value=r.cycles??'';$('#run-steps').value=r.steps??'';$('#experiment-name').value=state.draftName;$('#active-experiment-title').textContent=state.draftName||'New experiment';}
function inputFor(f,v){const k=esc(f.name),disabled=f.editable===false?'disabled':'';let h;if(f.type==='boolean')h=`<select id="param-${k}" data-param="${k}" ${disabled}><option value="true" ${v===true?'selected':''}>True</option><option value="false" ${v===false?'selected':''}>False</option></select>`;else if(f.type==='object'||f.type==='array')h=`<textarea id="param-${k}" data-param="${k}" ${disabled}>${esc(v==null?'':typeof v==='string'?v:JSON.stringify(v,null,2))}</textarea>`;else if(f.choices)h=`<select id="param-${k}" data-param="${k}" ${disabled}>${f.choices.map(c=>`<option value="${esc(c)}" ${v===c?'selected':''}>${esc(c)}</option>`).join('')}</select>`;else h=`<input id="param-${k}" data-param="${k}" ${disabled} type="${f.type==='number'||f.type==='integer'?'number':'text'}" step="${f.type==='integer'?'1':esc(f.step||'any')}" value="${v==null?'':esc(v)}" ${f.nullable?'':'required'}>`;if(f.nullable)h+=`<label class="null-toggle"><input type="checkbox" data-null="${k}" ${v===null?'checked':''}> Default</label>`;return h;}
function renderParameters(){const fs=(state.schema.fields||[]).filter(f=>f.editable),term=$('#parameter-search').value.toLowerCase(),groups=new Map();for(const f of fs){const g=f.group||'Other';if(!groups.has(g))groups.set(g,[]);groups.get(g).push(f);}let total=0,shown=0,h='';for(const[g,items]of groups){const before=shown,rows=items.map(f=>{const v=Object.hasOwn(state.spec.parameters,f.name)?state.spec.parameters[f.name]:f.default,show=`${f.name} ${f.label||''} ${f.description||''} ${g}`.toLowerCase().includes(term);total++;if(show)shown++;return`<div class="parameter-row" ${show?'':'hidden'}><label for="param-${esc(f.name)}"><abbr title="${esc(f.description||f.label||f.name)}">${esc(f.label||f.name)}</abbr></label><div>${inputFor(f,state.invalidFields.has(f.name)?state.invalidFields.get(f.name):v)}${f.unit?`<small>${esc(f.unit)}</small>`:''}</div></div>`;}).join('');if(shown>before)h+=`<section class="parameter-group"><div class="group-title">${esc(g)}</div>${rows}</section>`;}$('#parameter-fields').innerHTML=h;$('#parameter-count').textContent=`${shown} / ${total}`;}
function actions(){document.querySelectorAll('[data-action="save"],[data-action="save-run"]').forEach(b=>b.disabled=state.saving||state.deleting||!state.schema);const has=!!state.experiment;document.querySelectorAll('[data-action="clone"],[data-action="delete"],[data-action="export"]').forEach(b=>b.disabled=!has||(b.dataset.action==='delete'&&state.deleting));document.querySelector('[data-action="cancel"]').disabled=!state.job||!['queued','running'].includes(state.job.status);document.querySelector('[data-action="retry"]').disabled=!state.job||!['failed','cancelled','interrupted'].includes(state.job.status);const failure=$('#job-error'),message=state.job?.error||'';if(failure.textContent!==message)failure.textContent=message;failure.hidden=!message;}
function renderAll(){renderExperiments();renderConstructor();renderParameters();renderMaps();actions();}
function renderMaps(){$('.surface-map-controls').hidden=!state.schema.map_fields;const keys=Object.keys(state.spec.maps||{});$('#surface-map-status').textContent=keys.map(key=>state.schema.map_fields?.[key]?.label||key).join(' · ');$('[data-action="clear-maps"]').disabled=!keys.length;}
function mapValues(buffer){if(buffer.byteLength%4)throw Error('Map byte count is invalid.');const view=new DataView(buffer),values=new Float32Array(buffer.byteLength/4);for(let i=0;i<values.length;i++)values[i]=view.getFloat32(i*4,true);return values;}
async function openMaps(){const owner=state.spec,resolution=owner.model.resolution,fields=state.schema.map_fields,grid=state.schema.map_grids?.[resolution];if(!fields||!grid){notice('Map catalog is not available. Refresh the workbench after setup.',true);return;}try{const data=await request('maps',{query:{resolution}}),versions=data.maps;let terrain=null;const job=state.jobs.find(j=>j.kind==='planet'&&j.status==='completed'&&j.spec.model.resolution===resolution&&owner.surface&&Object.keys(state.schema.surface).every(k=>j.spec.surface?.[k]===owner.surface[k]));if(job){const result=await request('results',{query:{id:job.id}});terrain={job:job.id,index:result.dataset||result.index};}if(owner!==state.spec)return;const dialog=$('#map-editor-dialog');if(state.mapEditor)state.mapEditor.destroy();state.mapEditor=new window.ExoMapEditor($('#map-editor'),{
fields,onDirty:dirty=>{state.mapDirty=dirty;}, sources:async field=>{const saved=versions.filter(v=>v.field===field),entries=saved.map(v=>({id:v.id,name:v.name}));if(owner.maps?.[field])entries.sort((a,b)=>(b.id===owner.maps[field])-(a.id===owner.maps[field]));if(terrain&&['height','landmask'].includes(field)){const entry={id:'terrain',name:'Generated terrain'};owner.maps?.[field]?entries.push(entry):entries.unshift(entry);}entries.push({id:'blank',name:'Uniform map'});return entries;},
load:async(field,id)=>{if(id==='blank'){const values=new Float32Array(grid.lat.length*grid.lon.length);values.fill(fields[field].default??0);return{values,grid,name:fields[field].label};}if(id==='terrain'){const buffer=await request('frame',{query:{id:terrain.job,dataset:terrain.index.id,field:field==='height'?'elevation':'landmask',time:0,level:0}});return{values:mapValues(buffer),grid:{lat:terrain.index.lat,lon:terrain.index.lon},name:`${state.draftName} ${fields[field].label}`};}return{values:mapValues(await request('map',{query:{id}})),grid,name:versions.find(v=>v.id===id).name};},
save:async(field,name,values)=>{const buffer=new ArrayBuffer(values.length*4),view=new DataView(buffer);for(let i=0;i<values.length;i++)view.setFloat32(i*4,values[i],true);const response=await fetch(apiUrl('maps',{field,resolution,name}),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream'},body:buffer});const result=await response.json();if(!response.ok||!result.ok)throw Error(result.error||'Map save failed.');versions.push(result.data);return result.data;},
onSaved:(field,saved)=>{if(owner!==state.spec)return;state.spec.maps={...state.spec.maps,[field]:saved.id};setDirty();renderMaps();}
});dialog.showModal();await state.mapEditor.start();}catch(error){notice(error.message,true);}}
function closeMaps(){if(state.mapEditor?.canClose()){state.mapDirty=false;state.mapEditor.destroy();state.mapEditor=null;$('#map-editor-dialog').close();}}
function showErrors(e){const x=e.errors||[];$('#editor-errors').textContent=Array.isArray(x)?x.map(v=>typeof v==='string'?v:`${v.field||''} ${v.message||v.error||''}`).join('\n'):JSON.stringify(x);}
async function loadExperiments(){const d=await request('experiments');state.experiments=d.experiments||d.items||d||[];renderExperiments();}
async function loadJobs(){const d=await request('jobs');state.jobs=d.jobs||d.items||d||[];renderJobs();}
function receivedJob(job){const position=state.jobs.findIndex(item=>item.id===job.id);if(position<0)state.jobs.unshift(job);else state.jobs[position]=job;renderJobs();}
function renderJobs(){const markup=state.jobs.map(j=>`<li class="job-item"><button class="job-select" data-job="${esc(j.id)}" type="button" aria-current="${state.job?.id===j.id}"><span class="job-status ${esc(j.status)}">${esc(j.status)}</span><small>${esc(j.name)} · ${esc(j.phase||'queued')}</small>${['running','queued'].includes(j.status)?`<span class="progress-track"><span style="width:${Math.max(0,Math.min(100,Number(j.progress)||0))}%"></span></span>`:''}<small>${j.spec?.run?.steps==null?'Full-year cycle':`${esc(j.spec.run.steps)} steps per cycle`}</small></button></li>`).join('');if(markup!==state.jobsMarkup){const focused=document.activeElement.closest('[data-job]')?.dataset.job;$('#job-list').innerHTML=markup;state.jobsMarkup=markup;if(focused)$('#job-list').querySelector(`[data-job="${focused}"]`)?.focus({preventScroll:true});}}
function clearVisualization(){if(state.visual){state.visual.destroy();state.visual=null;}$('#visualization').innerHTML=`<div class="empty-results">${state.job&&['completed','failed','cancelled','running'].includes(state.job.status)?'Loading spatial output…':'No spatial output yet.'}</div>`;$('#visualization-downloads').innerHTML='';}
async function loadLog(reset=false,token=state.selection){if(!state.job)return;if(reset){state.logOffset=0;$('#job-log').textContent='';}try{const d=await request('log',{query:{id:state.job.id,offset:state.logOffset}});if(token!==state.selection)return;const t=d.text??d.log??'';$('#job-log').textContent=($('#job-log').textContent+t).slice(-65536);state.logOffset=d.next_offset??state.logOffset+new TextEncoder().encode(t).length;$('#log-cursor').textContent=t?`+${state.logOffset} bytes`:'';}catch(e){if(token===state.selection)$('#log-cursor').textContent=e.message;}}
function mountVisualization(d){
const c=$('#visualization');if(state.visual)state.visual.destroy();if(!window.ExoVisualization){c.textContent='Visualization module is not available.';return;}
const visual=state.visual=new window.ExoVisualization(c),jobId=state.job.id,gravity=state.job.spec?.parameters?.gravity??state.schema?.fields.find(meta=>meta.name==='gravity')?.implicit_default;
let datasetRequest=0,downloadDataset='';const ds=d.datasets||[],initial=d.dataset||d.index||d,toolbar=$('#visualization-downloads');
const select=document.createElement('select');select.className='dataset-select';select.setAttribute('aria-label','Dataset');
for(const x of ds){const o=document.createElement('option');o.value=x.id;o.textContent=x.label||x.id;o.selected=x.id===initial.id;select.append(o);}if(ds.length>1)toolbar.append(select);
const menu=document.createElement('select'),link=document.createElement('a');menu.className='dataset-select';menu.setAttribute('aria-label','Download file');link.textContent='Download';toolbar.append(menu,link);
const update=()=>{link.href=apiUrl('download',{id:jobId,artifact:menu.value});};menu.addEventListener('change',update);
function downloads(links,index){
const preferred=menu.value?menu.value.replace(':'+downloadDataset,':'+index.id):'';downloadDataset=index.id;menu.replaceChildren();
for(const artifact of links){const option=document.createElement('option');option.value=artifact.id;option.textContent=artifact.label||artifact.id;menu.append(option);}
menu.hidden=link.hidden=!links.length;if(!links.length)return;
menu.value=(links.find(artifact=>artifact.id===preferred)||links.find(artifact=>artifact.id==='raw:'+index.id||artifact.id.startsWith('raw:'+index.id+':'))||links[0]).id;update();
}
const use=(index,links)=>{visual.setDataset(index,(f,t,l)=>apiUrl('frame',{id:jobId,dataset:index.id,field:f,time:t,level:l}),(f,l)=>apiUrl('series',{id:jobId,dataset:index.id,field:f,level:l}),{gravity});downloads(links,index);};use(initial,d.downloads||[]);
select.addEventListener('change',async()=>{const current=++datasetRequest;try{const x=await request('results',{query:{id:jobId,dataset:select.value}});if(visual!==state.visual||current!==datasetRequest)return;use(x.dataset||x.index||x,x.downloads||[]);}catch(e){if(visual===state.visual&&current===datasetRequest)notice(e.message,true);}});
}
async function loadResult(token=state.selection){if(!state.job||!['completed','failed','cancelled','running'].includes(state.job.status)||state.resultLoaded===`${state.job.id}:${state.job.completed_cycles||0}`)return;const id=state.job.id,cycle=state.job.completed_cycles||0;try{const d=await request('results',{query:{id}});if(token!==state.selection||id!==state.job?.id)return;state.resultLoaded=`${id}:${cycle}`;mountVisualization(d);}catch(e){if(token===state.selection){state.resultLoaded=`${id}:${cycle}`;$('#visualization').textContent=e.status===404?'No spatial output yet.':e.message;if(e.status!==404)notice(e.message,true);}}}
async function selectJob(id){const token=++state.selection;state.job=state.jobs.find(x=>x.id===id)||null;document.querySelectorAll('[data-job]').forEach(button=>button.setAttribute('aria-current',button.dataset.job===id));state.resultLoaded='';clearVisualization();state.logOffset=0;$('#job-log').textContent='';await loadLog(true,token);if(token!==state.selection)return;await loadResult(token);actions();}
async function save(){if(state.saving||state.deleting)return false;const invalid=$('.parameter-pane input[type="number"]:invalid');if(invalid){invalid.reportValidity();invalid.focus();notice('Correct the invalid number before saving.',true);return false;}if(state.invalidFields.size){notice('Correct the invalid parameter values before saving.',true);return false;}const name=$('#experiment-name').value.trim();if(!name){notice('Enter an experiment name.',true);return false;}state.draftName=name;state.spec.model.resolution=$('#model-resolution').value;for(const k of ['layers','ncpus'])state.spec.model[k]=Number($(`#model-${k}`).value);state.spec.run.cycles=Number($('#run-cycles').value);state.spec.run.steps=$('#run-steps').value===''?null:Number($('#run-steps').value);const v=++state.saveVersion,revision=state.editVersion,editor=state.spec;state.saving=true;actions();$('#editor-errors').textContent='';try{const d=await request('experiments',{method:'POST',body:{...(state.experiment?.id?{id:state.experiment.id}:{}),name,spec:structuredClone(state.spec)}}),e=d.experiment||d;state.experiments=[e,...state.experiments.filter(item=>item.id!==e.id)];renderExperiments();if(v!==state.saveVersion||editor!==state.spec)return false;state.experiment=e;if(revision!==state.editVersion){notice('Earlier values saved. Current edits are not saved.');return false;}state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};setDirty(false);renderAll();notice('Experiment saved.');return true;}catch(e){showErrors(e);notice(e.message,true);return false;}finally{state.saving=false;actions();}}
async function deleteExperiment(){
if(state.deleting||!state.experiment)return;
const selected=state.experiment,revision=state.editVersion;
if(!confirm(`Delete ${selected.name}?`))return;
state.deleting=true;actions();
try{
await request('experiment_delete',{method:'POST',body:{id:selected.id}});
state.experiments=state.experiments.filter(item=>item.id!==selected.id);
if(state.experiment?.id===selected.id){
state.experiment=null;
if(revision===state.editVersion){state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();}
setDirty();renderAll();
}else renderExperiments();
notice('Experiment deleted.');
}catch(error){notice(error.message,true);}finally{state.deleting=false;actions();}
}
async function enqueue(){if(state.dirty&&!await save())return;if(!state.experiment)return;try{const j=await request('jobs',{method:'POST',body:{experiment_id:state.experiment.id}});receivedJob(j);if(j.id)await selectJob(j.id);notice('Run added to queue.');}catch(e){notice(e.message,true);}}
async function act(n,b){try{const d=await request(n,{method:'POST',body:b});receivedJob(d);if(n==='retry'&&d.id)await selectJob(d.id);else if(state.job)await selectJob(state.job.id);notice('Run updated.');}catch(e){notice(e.message,true);}}
function exportExperiment(){if(!state.experiment)return;const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify({name:state.experiment.name,spec:state.experiment.spec},null,2)],{type:'application/json'}));a.download=`${state.experiment.name.replace(/[^a-z0-9_-]+/gi,'-')}.json`;a.click();URL.revokeObjectURL(a.href);}
async function importExperiment(file){try{const p=JSON.parse(await file.text());if(!p?.spec||typeof p.spec!=='object'||Array.isArray(p.spec)||['model','parameters','run','surface'].some(k=>p.spec[k]!==undefined&&(!p.spec[k]||typeof p.spec[k]!=='object'||Array.isArray(p.spec[k]))))throw Error('This file does not contain a valid experiment.');state.invalidFields.clear();state.experiment=null;state.draftName=String(p.name||'Imported experiment');const defaults=freshSpec();state.spec={...structuredClone(p.spec),model:{...defaults.model,...p.spec.model},parameters:{...p.spec.parameters},run:{...defaults.run,...p.spec.run}};setDirty();renderAll();notice('Imported parameters. Save the experiment to keep it.');}catch(e){notice(e.message,true);}}
async function poll(){if(state.pollBusy||document.hidden)return;state.pollBusy=true;try{await loadJobs();if(state.job){const old=state.job.status;state.job=state.jobs.find(x=>x.id===state.job.id)||state.job;if(old!==state.job.status){state.resultLoaded='';clearVisualization();if(state.job.kind==='planet'&&state.job.status==='completed')await loadExperiments();}else if(state.job.status==='running'&&state.resultLoaded!==`${state.job.id}:${state.job.completed_cycles||0}`){state.resultLoaded='';}if(['queued','running'].includes(state.job.status))await loadLog();if(state.resultLoaded!==state.job.id)await loadResult();actions();}}catch(e){notice(e.message,true);}finally{state.pollBusy=false;}}
function randomSeed(){return crypto.getRandomValues(new Uint32Array(1))[0];}
document.addEventListener('click',async e=>{const x=e.target.closest('[data-experiment]');if(x){if(state.dirty&&!confirm('Discard unsaved changes?'))return;setExperiment(state.experiments.find(v=>v.id===x.dataset.experiment));return;}const j=e.target.closest('[data-job]');if(j){await selectJob(j.dataset.job);return;}const b=e.target.closest('[data-action]');if(!b)return;if(!state.schema&&!['refresh','cancel','retry'].includes(b.dataset.action)){notice('Model parameters are not loaded. Select Refresh to try again.',true);return;}switch(b.dataset.action){case'edit-maps':await openMaps();break;case'close-maps':closeMaps();break;case'clear-maps':if(confirm('Use the original surface maps?')){delete state.spec.maps;setDirty();renderMaps();}break;case'new':if(state.dirty&&!confirm('Discard unsaved changes?'))return;state.experiment=null;state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();break;case'save':await save();break;case'save-run':if(await save())await enqueue();break;case'clone':if(state.experiment){state.draftName=`${state.experiment.name} copy`;state.experiment=null;setDirty();renderAll();}break;case'delete':await deleteExperiment();break;case'export':exportExperiment();break;case'import':$('#import-file').click();break;case'refresh':if(!state.schema)await init();else await Promise.all([loadExperiments(),loadJobs()]);break;case'cancel':if(state.job)await act('cancel',{id:state.job.id});break;case'retry':if(state.job)await act('retry',{id:state.job.id});break;case'reset':if(confirm('Reset parameters to model defaults?')){state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();}break;case'generate':{const d=$('#generator-dialog'),f=$('#generator-form'),rules=state.schema.surface;for(const[k,rule]of Object.entries(rules)){const input=f.elements.namedItem(k);input.value=k==='seed'?randomSeed():rule.default;input.min=rule.min;input.max=rule.max;input.step=rule.type==='integer'?'1':'any';}d.showModal();break;}case'regenerate':$('#generator-form').seed.value=randomSeed();break;}});
document.addEventListener('input',e=>{if(e.target.id==='experiment-name'){state.draftName=e.target.value;$('#active-experiment-title').textContent=state.draftName||'New experiment';setDirty();}if(e.target.id==='experiment-search')renderExperiments();if(e.target.id==='parameter-search')renderParameters();if(e.target.matches('[data-param]')){const f=(state.schema.fields||[]).find(x=>x.name===e.target.dataset.param),v=e.target.value;if(f?.type==='object'||f?.type==='array'){try{state.spec.parameters[f.name]=v.trim()===''?null:JSON.parse(v);state.invalidFields.delete(f.name);$('#editor-errors').textContent='';setDirty();}catch{state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter valid JSON.`;}}else if((f?.type==='integer'||f?.type==='number')&&e.target.validity.badInput){state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter a valid number.`;}else{const value=(f?.type==='integer'||f?.type==='number')?(v===''?null:Number(v)):f?.type==='boolean'?v==='true':v;if(f?.type==='integer'&&value!==null&&!Number.isInteger(value)){state.invalidFields.set(f.name,v);$('#editor-errors').textContent=`${f.name}: Enter a whole number.`;}else{state.invalidFields.delete(f.name);state.spec.parameters[f.name]=value;}setDirty();}}if(e.target.matches('[data-param]')){const key=e.target.dataset.param,toggle=e.target.closest('.parameter-row').querySelector('[data-null]');if(toggle)toggle.checked=!state.invalidFields.has(key)&&state.spec.parameters[key]===null;if(!state.invalidFields.size)$('#editor-errors').textContent='';}if(e.target.closest('.constructor-grid'))setDirty();});
document.addEventListener('change',e=>{if(e.target.id==='import-file'&&e.target.files[0])importExperiment(e.target.files[0]);if(e.target.matches('[data-null]')){const k=e.target.dataset.null,control=e.target.closest('.parameter-row').querySelector('[data-param]');if(e.target.checked){state.spec.parameters[k]=null;state.invalidFields.delete(k);if(!state.invalidFields.size)$('#editor-errors').textContent='';control.value='';}else if(control.value!==''){control.dispatchEvent(new Event('input',{bubbles:true}));}else{state.invalidFields.set(k,'');$('#editor-errors').textContent=`${k}: Enter a value or select Default.`;control.focus();}setDirty();}if(e.target.id.startsWith('model-'))state.spec.model[e.target.id.slice(6)]=e.target.id==='model-resolution'?e.target.value:Number(e.target.value);if(e.target.id.startsWith('run-'))state.spec.run[e.target.id.slice(4)]=e.target.id==='run-steps'?(e.target.value===''?null:Number(e.target.value)):Number(e.target.value);});
$('#map-editor-dialog').addEventListener('cancel',e=>{e.preventDefault();closeMaps();});
$('#generator-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,status=$('#generator-status');const form=new FormData(f),surface={seed:Number(form.get('seed')),land_fraction:Number(form.get('land_fraction')),relief:Number(form.get('relief')),scale:Number(form.get('scale'))},rules=state.schema.surface;for(const[k,v]of Object.entries(surface)){const rule=rules[k]||{};if((typeof rule.min==='number'&&v<rule.min)||(typeof rule.max==='number'&&v>rule.max)){status.textContent=`${k} must be between ${rule.min} and ${rule.max}.`;return;}}status.textContent='';try{const data=await request('generate',{method:'POST',body:{name:state.draftName&&state.draftName!=='New experiment'?state.draftName:`Planet ${surface.seed}`,spec:{...structuredClone(state.spec),surface},surface}});$('#generator-dialog').close();receivedJob(data.job);notice('Planet generation added to queue.');if(data.job?.id)await selectJob(data.job.id);}catch(x){status.textContent=x.message;showErrors(x);}});
window.addEventListener('beforeunload',e=>{if(state.dirty||state.mapDirty){e.preventDefault();e.returnValue='';}});
async function init(){actions();try{state.schema=await request('schema');state.spec=freshSpec();state.draftName='New experiment';await Promise.all([loadExperiments(),loadJobs()]);renderAll();$('#workbench-notice').classList.remove('visible');if(state.jobs.length)await selectJob(state.jobs[0].id);}catch(e){notice(e.message,true);}if(!state.pollTimer)state.pollTimer=setInterval(poll,3500);if(!state.logTimer)state.logTimer=setInterval(()=>{if(state.job&&['queued','running'].includes(state.job.status)&&!document.hidden)loadLog();},1800);}init();
})();