Redesign touch controls and engagement interaction

This commit is contained in:
2026-08-26 19:29:39 +02:00
parent 7a9ab0e553
commit 3d47f479e6
4 changed files with 142 additions and 455 deletions
+33 -86
View File
@@ -57,15 +57,17 @@ function updateReadouts(){ if(appState!=='armed'){$('#surge-readout').textConten
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
heave.addEventListener('pointerdown',e=>{if(appState!=='armed')return;heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(appState!=='armed'||heavePointer!==null)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave();}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&engageButton?.classList.contains('arm')&&!e.repeat)startEngageHold();return;}if(e.key==='F1'){e.preventDefault();if(appState==='connected_disarmed'&&!engageButton?.classList.contains('arm')&&!e.repeat)engageButton?.click();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()});
function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(heavePointer===null)easeHeave();}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&!e.repeat)startEngageHold();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave()});
function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
@@ -234,67 +236,50 @@ if(customPortBtn&&customPortInput){
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
}
const engageButton=$('#engage');
let engageHoldTimer=null, engageStart=0, engageCharged=false, settingMode=false;
const ENGAGE_HOLD_MS=1000;
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
const ENGAGE_HOLD_MS=1400;
function isManualMode(s){
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
return m==='MANUAL';
}
function updateEngageProgress(){
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')){
stopEngageHold(true);
return;
}
const elapsed=performance.now()-engageStart;
const progress=Math.min(1,elapsed/ENGAGE_HOLD_MS);
engageButton.style.setProperty('--arm-progress',`${progress*100}%`);
if(progress>=1){
engageCharged=true;
stopEngageHold(false);
engageButton.classList.add('charged');
setTimeout(()=>engageButton.classList.remove('charged'),600);
if(demoActive){updateDemo({armed:true,app_state:'armed',vehicle:'DEMO • ARMED',status:'Demo: Arm confirmed'});toast('Demo vehicle armed')}
else api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
return;
}
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
function applyEngageVisual(next){
if(!engageButton||engageVisualState===next)return;
const previous=engageVisualState;engageVisualState=next;clearTimeout(engageTransitionTimer);
const commit=()=>{engageButton.classList.remove('arm','danger','subtle','charged');engageButton.classList.add(next==='disarm'?'danger':next==='engage'?'arm':'subtle');engageButton.disabled=next==='disabled';const label=next==='disarm'?'DISARM':'ENGAGE';$('#engage-label').textContent=label;if(engageProgressLabel)engageProgressLabel.textContent=label;engageButton.title=next==='disarm'?'Disarm immediately (F3)':next==='engage'?'Hold to Engage (F2)':'Engage';engageButton.setAttribute('aria-label',label)};
if((previous==='engage'&&next==='disarm')||(previous==='disarm'&&next==='engage')){engageButton.classList.add('state-transition');engageTransitionTimer=setTimeout(()=>{commit();engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),150)},120)}else commit();
}
async function completeEngage(){
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
try{if(demoActive){updateDemo({armed:true,app_state:'armed',vehicle:'DEMO • ARMED',status:'Demo: Arm confirmed'});toast('Demo vehicle armed')}else{await api('command','arm');toast('Engage command sent');setTimeout(()=>{if(appState!=='armed'){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%')}},2500)}}catch(error){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%');toast(error.message,true)}
}
function updateEngageProgress(now){
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return cancelEngageHold(false);
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function startEngageHold(e){
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return;
if(engageStart||engageCharged)return;
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return;
engageStart=performance.now();
engageCharged=false;
engageButton.classList.add('charging');
engageButton.style.setProperty('--arm-progress','0%');
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function stopEngageHold(wasCancelled=true){
function cancelEngageHold(showHint=true){
if(!engageStart)return;
const elapsed=performance.now()-engageStart;
cancelAnimationFrame(engageHoldTimer);
engageHoldTimer=null;
engageStart=0;
engageButton.classList.remove('charging');
engageButton.style.setProperty('--arm-progress','0%');
if(wasCancelled&&!engageCharged&&elapsed<ENGAGE_HOLD_MS&&elapsed>70){
toast('Hold for 1s to Engage vehicle');
}
engageCharged=false;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.style.setProperty('--engage-progress','0%');if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Keep holding to engage');
}
if(engageButton){
engageButton.addEventListener('pointerdown',startEngageHold);
engageButton.addEventListener('pointerup',()=>stopEngageHold(true));
engageButton.addEventListener('pointerleave',()=>stopEngageHold(true));
engageButton.addEventListener('pointercancel',()=>stopEngageHold(true));
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=async()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(appState==='armed'){
try{
if(demoActive)updateDemo({armed:false,app_state:'connected_disarmed',vehicle:'DEMO • DISARMED',status:'Demo: Disarm confirmed'});
@@ -303,18 +288,6 @@ if(engageButton){
}catch(e){
toast(e.message,true);
}
}else if(appState==='connected_disarmed'&&!engageButton.classList.contains('arm')){
try{
settingMode=true;
const lbl=$('#engage-label');
if(lbl)lbl.textContent='Setting mode…';
if(demoActive)updateDemo({mode:'MANUAL',status:'Demo: Manual mode confirmed'});
else await api('command','manual');
toast('Manual mode requested');
}catch(e){
settingMode=false;
toast(e.message,true);
}
}
};
}
@@ -351,33 +324,7 @@ function render(s){
const engageBtn=$('#engage');
const engageLabel=$('#engage-label');
if(engageBtn&&engageLabel){
if(appState==='armed'){
settingMode=false;
engageBtn.disabled=false;
engageBtn.className='button danger';
engageBtn.title='Disarm (F3)';
engageLabel.textContent='Disarm';
}else if(appState==='connected_disarmed'){
const manualConfirmed=isManualMode(s);
if(manualConfirmed){
settingMode=false;
engageBtn.disabled=false;
engageBtn.className='button arm';
engageBtn.title='Hold to Engage (F2)';
engageLabel.textContent='Hold to Engage';
}else{
engageBtn.disabled=false;
engageBtn.className='button subtle';
engageBtn.title='Set Manual Mode (F1)';
engageLabel.textContent=settingMode?'Setting mode…':'Engage';
}
}else{
settingMode=false;
engageBtn.disabled=true;
engageBtn.className='button subtle';
engageBtn.title='Engage';
engageLabel.textContent='Engage';
}
applyEngageVisual(appState==='armed'?'disarm':appState==='connected_disarmed'?'engage':'disabled');
}
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);