(function () { 'use strict'; const radians = Math.PI / 180; const clamp = (value, range) => Math.max(range.min, Math.min(range.max, value)); function paint(values, lat, lon, center, radius, strength, tool, range) { const source = tool === 'smooth' ? values.slice() : values; const phi = center.lat * radians, limit = Math.cos(radius * radians), cols = lon.length, sine = Math.sin(phi), cosine = Math.cos(phi); let changed = false; for (let y = 0; y < lat.length; y++) { const latitude = lat[y] * radians, offset = sine * Math.sin(latitude), scale = cosine * Math.cos(latitude); for (let x = 0; x < cols; x++) { const dot = offset + scale * Math.cos((lon[x] - center.lon) * radians); if (dot < limit) continue; const i = y * cols + x; let value = values[i]; if (tool === 'set') value = strength; else if (tool === 'smooth') { const above = Math.max(0, y - 1), below = Math.min(lat.length - 1, y + 1); value = (source[above * cols + x] + source[below * cols + x] + source[y * cols + (x + cols - 1) % cols] + source[y * cols + (x + 1) % cols] + source[i]) / 5; } else { const weight = (dot - limit) / (1 - limit); value += (tool === 'lower' ? -1 : 1) * Math.abs(strength) * weight; } value = Math.fround(clamp(value, range)); if (value !== values[i]) { values[i] = value; changed = true; } } } return changed; } window.ExoMapBrush = paint; window.ExoMapEditor = function (root, options) { let values = null, original = null, grid = null, rule = null, dirty = false, busy = false, saving = false, token = 0; let undo = [], redo = [], stroke = null, raf = 0, pending = null, last = null, range = {min:0,max:1}; const raster=document.createElement('canvas'),rasterContext=raster.getContext('2d'); let rasterImage=null,rasterRows=[]; root.innerHTML = `
`; const $ = selector => root.querySelector(selector), canvas = $('.map-editor-canvas'), ctx = canvas.getContext('2d'); const field = $('[data-map-field]'), source = $('[data-map-source]'), tool = $('[data-map-tool]'); const status = text => { $('[data-map-status]').textContent = text; }; const base=document.createElement('canvas');base.width=canvas.width;base.height=canvas.height;const baseContext=base.getContext('2d'); const listeners = new AbortController(); const on = (node, event, callback) => node.addEventListener(event, callback, {signal:listeners.signal}); let cursor = {lat:0,lon:0}; function controls() { $('[data-map-undo]').disabled = busy || !undo.length; $('[data-map-redo]').disabled = busy || !redo.length; $('[data-map-save]').disabled = busy || !values || !dirty; root.querySelectorAll('input,select,[data-map-ocean],[data-map-land]').forEach(control=>control.disabled=busy); options.onDirty(dirty); } function nearest(coords, value, periodic=false) { let best = 0, distance = Infinity; coords.forEach((coordinate, i) => { let d = Math.abs(coordinate - value); if(periodic) { d %= 360; d = Math.min(d,360-d); } if(d < distance) { distance=d;best=i; } }); return best; } function inspect() { if(!values)return; const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true); $('[data-map-position]').textContent=`${Number(grid.lat[y]).toFixed(2)}° latitude · ${Number(grid.lon[x]).toFixed(2)}° longitude · ${Number(values[y*grid.lon.length+x]).toPrecision(6)}${rule.unit==='1'?'':' '+rule.unit}`; } function drawCursor(){ if(!values)return;ctx.drawImage(base,0,0); const radius=Number($('[data-map-radius]').value)*radians,phi=cursor.lat*radians,start=grid.lon[0]-(grid.lon.length>1?(grid.lon[1]-grid.lon[0])/2:180); ctx.strokeStyle='#fff';ctx.lineWidth=1.5;ctx.beginPath();let previous=null; for(let i=0;i<=64;i++){const angle=i*Math.PI/32,latitude=Math.asin(Math.sin(phi)*Math.cos(radius)+Math.cos(phi)*Math.sin(radius)*Math.cos(angle));const longitude=cursor.lon+Math.atan2(Math.sin(angle)*Math.sin(radius)*Math.cos(phi),Math.cos(radius)-Math.sin(phi)*Math.sin(latitude))/radians; const x=(((longitude-start)%360+360)%360)/360*canvas.width,y=(90-latitude/radians)/180*canvas.height; if(previous===null||Math.abs(x-previous)>canvas.width/2)ctx.moveTo(x,y);else ctx.lineTo(x,y);previous=x; }ctx.stroke(); } function draw() { if(!values)return; const rows=grid.lat.length,cols=grid.lon.length; range={min:Infinity,max:-Infinity};for(const value of values){range.min=Math.min(range.min,value);range.max=Math.max(range.max,value);} if(!rasterImage){raster.width=cols;raster.height=rows*4;rasterImage=rasterContext.createImageData(cols,raster.height);rasterRows=Array.from({length:raster.height},(_,y)=>nearest(grid.lat,90-(y+.5)*180/raster.height));} const image=rasterImage; for(let y=0;y=.5?208:12):35+210*t; image.data[i+1]=rule.categorical?(value>=.5?204:41):40+205*t; image.data[i+2]=rule.categorical?(value>=.5?165:63):48+197*t; image.data[i+3]=255; } } rasterContext.putImageData(image,0,0);ctx.imageSmoothingEnabled=false;ctx.drawImage(raster,0,0,canvas.width,canvas.height);baseContext.drawImage(canvas,0,0);drawCursor(); $('[data-map-min]').textContent=`${range.min.toPrecision(6)} ${rule.unit}`; $('[data-map-max]').textContent=`${range.max.toPrecision(6)} ${rule.unit}`; inspect();controls(); } function keepHistory(previous) { undo.push(previous);if(undo.length>32)undo.shift();redo=[];dirty=true; } function dab(point) { const radius=Number($('[data-map-radius]').value),strength=Number($('[data-map-value]').value); if(!Number.isFinite(radius)||radius<1||radius>90||!Number.isFinite(strength)){status('Enter a finite value and a radius from 1° to 90°.');return false;} if(rule.categorical&&strength!==rule.min&&strength!==rule.max){status('Choose Ocean or Land.');return false;} if(tool.value==='set'&&(strengthrule.max)){status(`Value must be from ${rule.min} to ${rule.max} ${rule.unit}.`);return false;} return paint(values,grid.lat,grid.lon,point,radius,strength,tool.value,rule); } function flush() { raf=0;if(!pending||busy||!values)return; const point=pending;pending=null; let changed=false; const radius=Number($('[data-map-radius]').value),distance=last?Math.hypot(point.lat-last.lat,((point.lon-last.lon+540)%360)-180):0; const steps=Math.min(8,Math.max(1,Math.ceil(distance/Math.max(1,radius/2)))); for(let step=1;step<=steps;step++){const t=step/steps,delta=last?((point.lon-last.lon+540)%360)-180:0;changed=dab(last?{lat:last.lat+(point.lat-last.lat)*t,lon:last.lon+delta*t}:point)||changed;} if(changed){if(!stroke.changed){keepHistory(stroke.before);stroke.changed=true;}draw();status('Unsaved map changes.');} last=point;cursor=point;inspect(); } function position(event) { const box=canvas.getBoundingClientRect(),step=grid.lon.length>1?grid.lon[1]-grid.lon[0]:360; return {lat:Math.max(-90,Math.min(90,90-(event.clientY-box.top)/box.height*180)),lon:grid.lon[0]-step/2+(event.clientX-box.left)/box.width*360}; } on(canvas,'pointerdown',event=>{if(busy||!values||event.button!==0)return;event.preventDefault();canvas.focus({preventScroll:true});canvas.setPointerCapture(event.pointerId);stroke={before:values.slice(),changed:false};last=null;pending=position(event);flush();}); on(canvas,'pointermove',event=>{if(!values)return;cursor=position(event);inspect();drawCursor();if(!stroke)return;pending=cursor;if(!raf)raf=requestAnimationFrame(flush);}); const finish=()=>{if(raf){cancelAnimationFrame(raf);raf=0;}if(stroke&&pending)flush();stroke=null;last=null;pending=null;}; on(canvas,'pointerup',finish);on(canvas,'pointercancel',finish);on(canvas,'lostpointercapture',finish); on(canvas,'keydown',event=>{if(!values||busy)return;const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true);let row=y,col=x; if(event.key==='ArrowUp')row=Math.min(grid.lat.length-1,Math.max(0,y+(grid.lat[0]{$('[data-map-value]').value=rule.min;}); on($('[data-map-land]'),'click',()=>{$('[data-map-value]').value=rule.max;}); on($('[data-map-radius]'),'input',drawCursor); on($('[data-map-undo]'),'click',()=>{if(!undo.length||busy)return;redo.push(values.slice());values=undo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');}); on($('[data-map-redo]'),'click',()=>{if(!redo.length||busy)return;undo.push(values.slice());values=redo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');}); async function load() { const current=++token;busy=true;controls();ctx.clearRect(0,0,canvas.width,canvas.height);for(const selector of ['[data-map-min]','[data-map-max]','[data-map-position]'])$(selector).textContent='';status('Loading map…'); try { const loaded=await options.load(field.value,source.value);if(current!==token)return; values=loaded.values.slice();original=values.slice();grid=loaded.grid;rule=options.fields[field.value];rasterImage=null; if(values.length!==grid.lat.length*grid.lon.length||Array.from(values).some(v=>!Number.isFinite(v)))throw Error('Map data does not match its grid.'); undo=[];redo=[];dirty=false;cursor={lat:0,lon:0};$('[data-map-name]').value=loaded.name||rule.label; $('[data-map-unit]').textContent=rule.unit==='1'?'Value':`Value (${rule.unit})`; $('[data-map-ocean]').hidden=$('[data-map-land]').hidden=!rule.categorical;$('[data-map-value]').value=rule.categorical?rule.max:(rule.brush_step??(rule.max-rule.min)/100); for(const option of tool.options)option.disabled=Boolean(rule.categorical)&&option.value!=='set';tool.value=rule.categorical?'set':'raise'; draw();status(''); }catch(error){if(current===token){values=null;ctx.clearRect(0,0,canvas.width,canvas.height);status(error.message);}} finally{if(current===token){busy=false;controls();}} } async function sources() { const entries=await options.sources(field.value);source.replaceChildren(); for(const entry of entries){const option=document.createElement('option');option.value=entry.id;option.textContent=entry.name;source.append(option);} await load(); } function canDiscard(){return !dirty||confirm('Discard unsaved map changes?');} let previousField='',previousSource=''; on(field,'change',async()=>{if(!canDiscard()){field.value=previousField;return;}previousField=field.value;await sources();previousSource=source.value;}); on(source,'change',async()=>{if(!canDiscard()){source.value=previousSource;return;}previousSource=source.value;await load();}); on($('[data-map-save]'),'click',async()=>{if(busy||!values)return;finish();const name=$('[data-map-name]').value.trim();if(!name){status('Enter a map name.');return;}busy=true;saving=true;controls();status('Saving map…'); try{const saved=await options.save(field.value,name,values.slice());dirty=false;original=values.slice();undo=[];redo=[];status('Map saved. Save the experiment to use it.');options.onSaved(field.value,saved);} catch(error){status(error.message);}finally{busy=false;saving=false;controls();} }); for(const [name,metadata]of Object.entries(options.fields)){const option=document.createElement('option');option.value=name;option.textContent=metadata.label+(metadata.unit==='1'?'':` (${metadata.unit})`);field.append(option);} this.start=async()=>{previousField=field.value;await sources();previousSource=source.value;}; this.canClose=()=>!saving&&canDiscard(); this.destroy=()=>{token++;listeners.abort();if(raf)cancelAnimationFrame(raf);root.replaceChildren();}; }; })();