162 lines
14 KiB
JavaScript
162 lines
14 KiB
JavaScript
(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 = `<div class="map-editor-controls"><label>Map <select data-map-field></select></label><label>Source <select data-map-source></select></label><label>Name <input data-map-name maxlength="120"></label><label>Tool <select data-map-tool><option value="raise">Raise</option><option value="lower">Lower</option><option value="set">Set value</option><option value="smooth">Smooth</option></select></label><label>Radius (°) <input data-map-radius type="number" value="8" min="1" max="90" step="1"></label><label><span data-map-unit>Value</span> <input data-map-value type="number" step="any"></label><button type="button" data-map-ocean hidden>Ocean</button><button type="button" data-map-land hidden>Land</button><button type="button" data-map-undo>Undo</button><button type="button" data-map-redo>Redo</button><button type="button" data-map-save>Save map</button></div><canvas class="map-editor-canvas" width="640" height="320" tabindex="0" aria-label="Editable surface map. Arrow keys move the brush. Space paints."></canvas><div class="map-editor-scale"><span data-map-min></span><span data-map-max></span></div><output data-map-position></output><output data-map-status role="status"></output>`;
|
|
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<raster.height;y++){
|
|
const row=rasterRows[y];
|
|
for(let x=0;x<cols;x++){
|
|
const value=values[row*cols+x],t=range.max===range.min?.5:(value-range.min)/(range.max-range.min),i=(y*cols+x)*4;
|
|
image.data[i]=rule.categorical?(value>=.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'&&(strength<rule.min||strength>rule.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]<grid.lat.at(-1)?1:-1)));
|
|
else if(event.key==='ArrowDown')row=Math.min(grid.lat.length-1,Math.max(0,y+(grid.lat[0]<grid.lat.at(-1)?-1:1)));
|
|
else if(event.key==='ArrowLeft')col=(x+grid.lon.length-1)%grid.lon.length;
|
|
else if(event.key==='ArrowRight')col=(x+1)%grid.lon.length;
|
|
else if(event.key===' '){event.preventDefault();const before=values.slice();if(dab(cursor)){keepHistory(before);draw();}return;}else return;
|
|
event.preventDefault();cursor={lat:grid.lat[row],lon:grid.lon[col]};inspect();drawCursor();
|
|
});
|
|
on($('[data-map-ocean]'),'click',()=>{$('[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();};
|
|
};
|
|
})();
|