/* Controller conditioning is independent of drawing and MAVLink transport. */ ((root)=>{ const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0)); function deadZone(value,zone=.08){ value=clamp(value);zone=Math.max(0,Math.min(.4,zone)); return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone); } function defaults(pad){ return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1, invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08}; } function read(pad,config){ if(config?.version===2)return readProfile(pad,config); if(!pad?.connected||!pad.axes)return null; const axis=(index,invert)=>{ if(index===-1)return 0; if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null; return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1); }; let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical); if(turn===null||forward===null||vertical===null)return null; const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length} return {turn,forward,vertical}; } class Smoother{ constructor(){this.reset()} reset(){this.value=0;this.last=null;this.release=null} step(target,now){ target=clamp(target); const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now; if(target===0){ if(!this.release)this.release={at:now,start:this.value}; const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t); this.value=this.release.start*(1-ease); }else{ this.release=null; this.value+=(target-this.value)*(1-Math.exp(-dt/.1)); if(Math.abs(target-this.value)<.001)this.value=target; } return this.value; } } class Motion{ constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}} reset(){Object.values(this.axes).forEach(axis=>axis.reset())} step(target,now){ const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)])); const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length} return demand; } } const names=['forward','lateral','vertical','turn','pitch','roll']; const pair=(negative='off',positive='off')=>({negative,positive,scale:1}); function preset(name='triggers3'){ const bindings=Object.fromEntries(names.map(axis=>[axis,pair()])); bindings.forward=pair('button:6','button:7'); bindings.vertical=pair('button:0','button:1'); bindings.turn=pair('axis:0:-','axis:0:+'); if(name==='sixaxis'){ bindings.lateral=pair('axis:2:-','axis:2:+'); bindings.pitch=pair('axis:1:-','axis:1:+'); bindings.roll=pair('button:4','button:5'); }else if(name==='classic'){ bindings.forward=pair('axis:1:+','axis:1:-'); bindings.vertical=pair('axis:3:+','axis:3:-'); } return {version:2,preset:name,deadZone:.08,expo:.15,bindings, actions:{lights:'button:2',stream:'button:3',record:'button:8'}}; } function migrate(config,pad){ if(config?.version===2)return structuredClone(config); if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3'); const result=preset('classic'); for(const axis of ['forward','vertical','turn']){ const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)]; result.bindings[axis]=Number.isInteger(index)&&index>=0? pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair(); } result.deadZone=config.deadZone??.08; result.actions={lights:'off',stream:'off',record:'off'}; return result; } function validProfile(config){ const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value); return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&& Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{ const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&& (item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1; })&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:')); } function inputValue(pad,source,zone){ if(source==='off')return 0; const parts=source.split(':'),index=Number(parts[1]); if(parts[0]==='button'){ const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null; return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0); } const value=pad.axes?.[index]; return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null; } function readProfile(pad,config){ if(!pad?.connected||!validProfile(config))return null; const result={}; for(const name of names){ const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone); if(negative===null||positive===null)return null; const value=clamp(positive-negative); result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale; } return result; } function actions(pad,config){ return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5])); } function sources(pad){ const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y']; for(let index=0;index