V2
This commit is contained in:
+205
-66
@@ -6,11 +6,25 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
function recordActivity(message){
|
||||
if(!message||activity[0]===message)return;
|
||||
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
|
||||
});
|
||||
}
|
||||
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
|
||||
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
|
||||
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
|
||||
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
|
||||
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
|
||||
window.canDrive=canDrive;
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
@@ -51,23 +65,51 @@ function populatePorts(ports){
|
||||
p.value=allList[0].device;
|
||||
}
|
||||
}
|
||||
function sendAxes(){if(appState!=='armed')return;if(demoActive){demoState.status=`Demo controls • surge ${control.forward.toFixed(2)} • yaw ${control.turn.toFixed(2)} • heave ${control.vertical.toFixed(2)}`;$('#status-text').textContent=demoState.status;return}if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||
function updateReadouts(){ if(appState!=='armed'){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
function sendAxes(){
|
||||
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
|
||||
const transmit=()=>{
|
||||
axisTimer=0;
|
||||
if(!canDrive()||demoActive||axisBusy)return;
|
||||
lastAxisSend=performance.now();axisBusy=true;
|
||||
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
||||
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||
};
|
||||
const wait=50-(performance.now()-lastAxisSend);
|
||||
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
|
||||
}
|
||||
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
||||
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
|
||||
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
window.setJoyControl=(x,y)=>{if(!canDrive())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 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 setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);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&&canDrive())sendAxes();}
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
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)});
|
||||
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)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(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 applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function resetInputs(){
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||
window.joystick3D?.reset();
|
||||
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||
hknob.classList.remove('pressed','released');
|
||||
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
||||
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||
}
|
||||
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||
addEventListener('blur',()=>focusChanged(false));
|
||||
addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function openModal(id){
|
||||
const el=typeof id==='string'?document.getElementById(id):id;
|
||||
@@ -166,7 +208,14 @@ if(rovStage){
|
||||
if(file)handleStlFile(file);
|
||||
});
|
||||
}
|
||||
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};
|
||||
$('#refresh').onclick=async e=>{
|
||||
const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
|
||||
try{
|
||||
if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
if(!booted)boot();
|
||||
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
|
||||
};
|
||||
$('#connect').onclick=async()=>{
|
||||
const btn=$('#connect');
|
||||
const label=btn.querySelector('.btn-label')||btn;
|
||||
@@ -174,6 +223,7 @@ $('#connect').onclick=async()=>{
|
||||
const port=p?p.value:'';
|
||||
|
||||
if(port==='demo'||demoActive){
|
||||
resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
|
||||
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
|
||||
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
|
||||
return;
|
||||
@@ -186,6 +236,7 @@ $('#connect').onclick=async()=>{
|
||||
}
|
||||
|
||||
if(appState!=='disconnected'){
|
||||
resetInputs();
|
||||
label.textContent='Disconnecting…';
|
||||
try{
|
||||
await api('disconnect');
|
||||
@@ -201,17 +252,17 @@ $('#connect').onclick=async()=>{
|
||||
btn.classList.add('is-connecting');
|
||||
label.textContent='Connecting…';
|
||||
const statusEl=$('#status-text');
|
||||
if(statusEl)statusEl.textContent=`Opening ${port}…`;
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||
if(confirmedState)render(confirmedState);
|
||||
toast('MAVLink connected');
|
||||
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||
}catch(e){
|
||||
btn.classList.remove('is-connecting');
|
||||
label.textContent='Connect';
|
||||
toast(e.message,true);
|
||||
if(statusEl)statusEl.textContent=`Connection error: ${e.message}`;
|
||||
recordActivity(`Connection error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -240,27 +291,69 @@ 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 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();
|
||||
if(!engageButton)return;
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Interaction updates immediately; the short fade never gates emergency access.
|
||||
engageButton.disabled=!next.action;
|
||||
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||
engageButton.setAttribute('aria-label',label);
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||
const key=`${next.label}/${next.tone}`;
|
||||
if(engageVisualState===key)return;
|
||||
const previous=engageVisualState;engageVisualState=key;
|
||||
clearTimeout(engageTransitionTimer);
|
||||
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||
$('#engage-label').textContent=label;
|
||||
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||
if(previous){
|
||||
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||
engageButton.classList.add('state-transition');
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||
}
|
||||
}
|
||||
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 completeEngage(){
|
||||
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
|
||||
requestControl('arm');
|
||||
}
|
||||
async function requestControl(name){
|
||||
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
|
||||
const id=++requestSerial;
|
||||
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
|
||||
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
|
||||
resetInputs();render(vehicleSnapshot);
|
||||
try{
|
||||
if(demoActive){
|
||||
// Separate simulated request and simulated heartbeat, just like the real UI.
|
||||
clearTimeout(demoCommandTimer);
|
||||
updateDemo({pending_command:name,command_error:''});
|
||||
demoCommandTimer=setTimeout(()=>{
|
||||
if(!demoActive||id!==requestSerial)return;
|
||||
localRequest=null;
|
||||
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
|
||||
app_state:name==='arm'?'armed':'connected_disarmed',
|
||||
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
|
||||
},650);
|
||||
}else{
|
||||
await api('command',name,++inputSequence,Date.now()+2000);
|
||||
if(id===requestSerial&&localRequest)localRequest.sent=true;
|
||||
// Only poll snapshots may confirm state; a send result is not confirmation.
|
||||
}
|
||||
}catch(error){
|
||||
if(id!==requestSerial)return;
|
||||
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
|
||||
render(vehicleSnapshot);toast(commandError,true);
|
||||
}
|
||||
}
|
||||
function updateEngageProgress(now){
|
||||
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return cancelEngageHold(false);
|
||||
if(!engageStart||view.action!=='arm'||!inputFocused)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')||engageStart)return;
|
||||
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
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);
|
||||
@@ -268,39 +361,51 @@ function startEngageHold(e){
|
||||
}
|
||||
function cancelEngageHold(showHint=true){
|
||||
if(!engageStart)return;
|
||||
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');
|
||||
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||
}
|
||||
|
||||
if(engageButton){
|
||||
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||
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('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}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('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
|
||||
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
|
||||
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
engageButton.onclick=async()=>{
|
||||
engageButton.onclick=()=>{
|
||||
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'});
|
||||
else await api('command','disarm');
|
||||
toast('Disarm command sent');
|
||||
}catch(e){
|
||||
toast(e.message,true);
|
||||
}
|
||||
}
|
||||
if(view.action==='disarm')requestControl('disarm');
|
||||
};
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
// in the embedded window, without depending on native range drag defaults.
|
||||
const gainSlider=$('#gain');let gainPointer=null;
|
||||
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
|
||||
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
|
||||
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||
function render(s){
|
||||
appState=s.app_state||'disconnected';
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||
const previous=view;
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||
document.body.dataset.appState=appState;
|
||||
document.body.dataset.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -324,24 +429,26 @@ function render(s){
|
||||
const engageBtn=$('#engage');
|
||||
const engageLabel=$('#engage-label');
|
||||
if(engageBtn&&engageLabel){
|
||||
applyEngageVisual(appState==='armed'?'disarm':appState==='connected_disarmed'?'engage':'disabled');
|
||||
applyEngageVisual(view);
|
||||
}
|
||||
const controlStatus=$('#control-status');
|
||||
controlStatus.textContent=view.message;
|
||||
controlStatus.classList.toggle('is-error',view.error);
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
const statusEl=$('#status-text');
|
||||
if(statusEl){
|
||||
if(s.status){
|
||||
statusEl.textContent=s.status;
|
||||
}else{
|
||||
statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected;
|
||||
}
|
||||
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||
if(confirmedCommand){
|
||||
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
|
||||
}
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
}else{
|
||||
@@ -349,10 +456,31 @@ function render(s){
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-fill').style.width='0';
|
||||
}
|
||||
renderMotors(s.servos);
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
|
||||
async function poll(){if(!bridgeReady)return;if(!demoActive)try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||
function markBridgeStale(){
|
||||
if(demoActive||bridgeStale)return;
|
||||
bridgeStale=true;resetInputs();render(vehicleSnapshot);
|
||||
}
|
||||
setInterval(()=>{
|
||||
const now=performance.now();
|
||||
if(now-lastSafetyTick>750)resetInputs();
|
||||
lastSafetyTick=now;
|
||||
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
|
||||
if(localRequest&&performance.now()>localRequest.deadline){
|
||||
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
|
||||
localRequest=null;render(vehicleSnapshot);
|
||||
}
|
||||
},100);
|
||||
async function poll(){
|
||||
if(!bridgeReady)return;
|
||||
if(!demoActive)try{
|
||||
const snapshot=await withTimeout(api('poll'));
|
||||
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
|
||||
}catch(e){markBridgeStale()}
|
||||
setTimeout(poll,250);
|
||||
}
|
||||
let modelLoadPromise=null;
|
||||
async function loadDefaultModel(){
|
||||
if(modelLoadPromise)return modelLoadPromise;
|
||||
@@ -426,35 +554,46 @@ async function loadDefaultModel(){
|
||||
return modelLoadPromise;
|
||||
}
|
||||
|
||||
let booted=false;
|
||||
let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
|
||||
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
|
||||
async function boot(){
|
||||
if(booted||!window.pywebview?.api)return;
|
||||
booted=true;
|
||||
bridgeReady=true;
|
||||
if(booted||booting||!hasDesktopApi())return;
|
||||
booting=true;clearTimeout(bootRetry);
|
||||
try{
|
||||
const init=await api('initial_state');
|
||||
batteryConfig=init.battery_config;
|
||||
// OS port discovery must not depend on model/config initialization succeeding.
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
render(init.state);
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
// Populate the serial selector before starting the optional model load.
|
||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||
loadDefaultModel();
|
||||
setInterval(async()=>{
|
||||
if(appState==='disconnected'){
|
||||
try{populatePorts(await api('refresh_ports'))}catch(_){}
|
||||
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||
if(appState==='disconnected'||demoActive){
|
||||
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
|
||||
}
|
||||
},2000);
|
||||
}catch(e){
|
||||
booted=false;bridgeReady=false;
|
||||
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
|
||||
toast(`Python bridge: ${e.message}`,true);
|
||||
bootRetry=setTimeout(boot,1000);
|
||||
}finally{
|
||||
booting=false;
|
||||
}
|
||||
}
|
||||
function waitForBridge(){
|
||||
if(window.pywebview?.api)boot();
|
||||
else setTimeout(waitForBridge,50);
|
||||
if(booted)return;
|
||||
if(hasDesktopApi())boot();
|
||||
setTimeout(waitForBridge,250);
|
||||
}
|
||||
addEventListener('pywebviewready',boot);
|
||||
render(vehicleSnapshot);
|
||||
if(document.readyState==='loading'){
|
||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||
}else{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Presentation derived from telemetry. No animation is allowed to set armed. */
|
||||
((root) => {
|
||||
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
|
||||
const known = state.armed_known === true && !stale;
|
||||
const online = !!state.connected && !stale;
|
||||
const request = pending || state.pending_command;
|
||||
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
|
||||
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
|
||||
if (request === 'disarm') {
|
||||
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
|
||||
} else if (request === 'arm') {
|
||||
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
|
||||
message='Awaiting vehicle confirmation. Tap to request disarm.';
|
||||
} else if (known && state.armed) {
|
||||
label='DISARM'; tone='danger'; action=online?'disarm':null;
|
||||
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
|
||||
} else if (uncertain) {
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
return {label,tone,action,pending:!!request,known,
|
||||
drive:known && online && state.armed === true && !request && focused,
|
||||
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
|
||||
error:!!(error || state.command_error || uncertain || stale)};
|
||||
}
|
||||
root.ControlState={present};
|
||||
if(typeof module!=='undefined') module.exports=root.ControlState;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
+31
-36
@@ -4,13 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sub Control</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20260826-controls-v2">
|
||||
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
|
||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||
<aside class="sidebar-deck pywebview-drag-region" aria-label="Vehicle Deck and Status">
|
||||
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||
<!-- Connection Controls -->
|
||||
<div class="deck-connection">
|
||||
<div class="port-row">
|
||||
@@ -33,9 +34,7 @@
|
||||
<!-- Telemetry / Status Zone -->
|
||||
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||
<div class="deck-card model-card">
|
||||
<div class="eyebrow">live vehicle</div>
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div id="model-status" class="model-status">Initializing…</div>
|
||||
<div class="model-tools">
|
||||
@@ -43,11 +42,6 @@
|
||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-name">
|
||||
<small>vehicle</small>
|
||||
<strong>SimpleROV-3</strong>
|
||||
<span>3 thrusters • differential drive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Battery Status -->
|
||||
@@ -57,27 +51,15 @@
|
||||
</button>
|
||||
|
||||
<div class="deck-status-bar">
|
||||
<div class="status-line">
|
||||
<span id="status-dot" class="status-dot"></span>
|
||||
<p id="status-text">Disconnected — select a port and connect</p>
|
||||
<a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a>
|
||||
</div>
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</ol>
|
||||
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Output Limit & Motor Setup -->
|
||||
<div class="deck-card controls-deck">
|
||||
<label class="output-limit">
|
||||
<span>output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||
<input id="gain" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor Setup</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ── PRIMARY CONTROL ZONE ── -->
|
||||
@@ -95,13 +77,14 @@
|
||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||
<!-- Reserved Engage / Disarm row -->
|
||||
<div class="engage-header-zone">
|
||||
<span class="zone-label">vehicle control</span>
|
||||
<button id="engage" class="button subtle engage-button" aria-label="Engage" title="Engage (F2)">
|
||||
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
|
||||
<span class="engage-face">
|
||||
<span id="engage-label" class="engage-label">ENGAGE</span>
|
||||
<span class="engage-progress-label" aria-hidden="true">ENGAGE</span>
|
||||
<span id="engage-label" class="engage-label">engage</span>
|
||||
<span class="engage-sheen-label" aria-hidden="true">engage</span>
|
||||
<span class="engage-progress-label" aria-hidden="true">engage</span>
|
||||
</span>
|
||||
</button>
|
||||
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Joysticks & Thrust Control Grid -->
|
||||
@@ -109,12 +92,12 @@
|
||||
<!-- Steering / Throttle Joystick -->
|
||||
<article class="control-card steer-card">
|
||||
<header>
|
||||
<div><small>steer + throttle</small><strong>differential drive</strong></div>
|
||||
<div><small>steer + throttle</small></div>
|
||||
<span class="key-hint">w a s d</span>
|
||||
</header>
|
||||
<div class="joystick-pad">
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
|
||||
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
@@ -126,7 +109,7 @@
|
||||
<!-- Vertical Thruster Slider -->
|
||||
<article class="control-card vertical-card">
|
||||
<header>
|
||||
<div><small>vertical</small><strong>center thruster</strong></div>
|
||||
<div><small>vertical</small></div>
|
||||
<span class="key-hint">r / f</span>
|
||||
</header>
|
||||
<div class="heave-pad">
|
||||
@@ -145,6 +128,17 @@
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div class="control-toolbar">
|
||||
<label class="output-limit">
|
||||
<span>Output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
@@ -157,7 +151,8 @@
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
|
||||
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||
<script src="joystick-3d.js?v=20260826-controls-v2"></script>
|
||||
<script src="app.js?v=20260826-controls-v2"></script>
|
||||
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="app.js?v=20260827-bridge-v6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||
|
||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eef1ee';ctx.fill();ctx.strokeStyle='#cfd5d1';ctx.lineWidth=1.5;ctx.stroke();
|
||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||
|
||||
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#4d5652':'#303936';ctx.fill();ctx.restore();
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||
}
|
||||
@@ -33,10 +33,10 @@
|
||||
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||
|
||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||
addEventListener('resize',resize);resize();
|
||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},getState(){return {x,y,active:activePointer!==null}}};
|
||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||
})();
|
||||
|
||||
+126
-19
@@ -118,7 +118,7 @@ body.maximized .app-shell {
|
||||
transition: transform 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.button:hover, .icon-button:hover {
|
||||
transform: translateY(-1px);
|
||||
transform: none;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: #293237;
|
||||
}
|
||||
@@ -500,15 +500,15 @@ body[data-app-state="connecting"] .status-line .status-dot {
|
||||
}
|
||||
body[data-app-state="connected_disarmed"] .status-line .status-dot {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 6px rgba(114, 216, 232, 0.6);
|
||||
box-shadow: none;
|
||||
}
|
||||
body[data-app-state="armed"] .status-line .status-dot {
|
||||
background: var(--acid);
|
||||
box-shadow: 0 0 6px rgba(201, 242, 119, 0.65);
|
||||
box-shadow: none;
|
||||
}
|
||||
body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px rgba(240, 149, 139, 0.65);
|
||||
box-shadow: none;
|
||||
}
|
||||
.status-line p {
|
||||
margin: 0;
|
||||
@@ -603,10 +603,11 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
|
||||
/* ── Engage / Disarm Slot Above Joysticks ── */
|
||||
.engage-header-zone {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
min-height: 76px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.zone-label {
|
||||
@@ -617,13 +618,13 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
}
|
||||
@property --engage-progress {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
inherits: true;
|
||||
initial-value: 0%;
|
||||
}
|
||||
.engage-button {
|
||||
--engage-progress: 0%;
|
||||
min-width: 166px;
|
||||
height: 38px;
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
@@ -653,15 +654,26 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
.arm:not(.charging):not(.state-transition) .engage-label { animation: engage-shimmer 2.8s linear infinite; }
|
||||
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
|
||||
.engage-progress-label {
|
||||
color: #a9ed72;
|
||||
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
||||
transition: clip-path .06s linear, opacity .18s ease;
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
.state-transition .engage-face { opacity: .2; transform: translateY(-2px); }
|
||||
.engage-face { transition: opacity .14s ease, transform .14s ease; }
|
||||
@keyframes engage-shimmer { from{background-position:160% 0} to{background-position:-90% 0} }
|
||||
.state-transition .engage-face { animation: state-label .24s ease-out; }
|
||||
@keyframes state-label { from {opacity:.35} to {opacity:1} }
|
||||
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
|
||||
.engage-button:hover { transform:none; }
|
||||
.engage-button.pending { background:#405346; border-color:#516e56; }
|
||||
.engage-button.pending .engage-label { color:#f4f6f3; background:none; }
|
||||
.engage-button.pending .engage-progress-label { opacity:0; }
|
||||
.control-status { grid-column:1/-1; min-height:28px; margin:6px 0 0; font-size:12px; line-height:1.4; color:#64716a; transition:color .18s ease; }
|
||||
.control-status.is-error { color:#a23931; }
|
||||
.controls-grid { transition:opacity .18s ease; }
|
||||
.heave-track[data-commanded="true"] .heave-center { background:#3d5145; }
|
||||
.heave-track .heave-center, .heave-fill { transition:background-color .12s linear; }
|
||||
body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
#battery-fill { transition:width .25s linear, opacity .18s ease; }
|
||||
.subtle { background: #e2e6e1; color: #28302c; border: 1px solid rgba(0, 0, 0, 0.08); }
|
||||
.subtle:hover { background: #d7dcd5; }
|
||||
|
||||
@@ -832,7 +844,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.heave-knob:active, .heave-knob.pressed {
|
||||
scale: 0.93;
|
||||
filter: brightness(.83);
|
||||
}
|
||||
|
||||
.control-card footer {
|
||||
@@ -978,7 +990,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
APP-STATE CONDITIONAL GATING
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
body:not([data-app-state="armed"]) .controls-grid {
|
||||
body:not([data-controls-enabled="true"]) .controls-grid {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
@@ -991,10 +1003,6 @@ button:disabled {
|
||||
}
|
||||
button:disabled::after { display: none !important; }
|
||||
|
||||
body[data-app-state="disconnected"] #engage,
|
||||
body[data-app-state="connecting"] #engage,
|
||||
body[data-app-state="link_degraded"] #engage,
|
||||
body[data-app-state="connecting"] #connect,
|
||||
body[data-app-state="disconnected"] button[data-open="motors-modal"],
|
||||
body[data-app-state="connecting"] button[data-open="motors-modal"],
|
||||
body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||
@@ -1003,8 +1011,107 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* A pending or unknown armed state must stay legible, even without a link. */
|
||||
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation:none !important; transition:none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.app-shell { grid-template-columns: 210px minmax(0, 1fr); }
|
||||
.controls-grid { grid-template-columns: minmax(230px, 1fr) 150px; }
|
||||
.controls-workspace { padding: 8px 12px; }
|
||||
}
|
||||
|
||||
/* Compact control surface: interactive content is never a native drag region. */
|
||||
.app-shell { grid-template-columns:260px minmax(0,1fr); }
|
||||
.sidebar-deck { position:relative; padding:20px; gap:18px; background:linear-gradient(160deg,#333,#242424); }
|
||||
.sidebar-drag-surface { position:absolute; inset:0; cursor:grab; }
|
||||
.sidebar-deck > :not(.sidebar-drag-surface) { position:relative; pointer-events:none; }
|
||||
.sidebar-deck :is(button,select,input,a,.rov-stage,.activity-history) { pointer-events:auto; }
|
||||
.deck-connection { gap:12px; padding-bottom:16px; }
|
||||
.port-row select { height:36px; font-size:12px; border-radius:999px; background:#3a3a3a; }
|
||||
.icon-button { width:34px; height:34px; background:#3a3a3a; }
|
||||
body #connect, body #connect:hover, body #connect.is-connected, body #connect.is-connecting {
|
||||
width:100%; min-width:0; height:62px; padding:0 14px; align-self:stretch;
|
||||
background:#fff !important; color:#303030 !important; border:0 !important;
|
||||
border-radius:999px; box-shadow:none !important; font-size:30px; font-weight:300; letter-spacing:-.025em;
|
||||
}
|
||||
body #connect .btn-label { animation:none; }
|
||||
.telemetry-zone { gap:20px; }
|
||||
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
|
||||
.model-card { justify-content:flex-start; }
|
||||
.rov-stage::before { display:none; }
|
||||
.model-tools button { font-size:12px; padding:7px 11px; }
|
||||
.battery-card small { font-size:12px; }
|
||||
.battery-card strong { font-size:23px; }
|
||||
.battery-card i { font-size:12px; }
|
||||
.activity-heading { display:flex; align-items:center; justify-content:space-between; font-size:14px; font-weight:650; color:#eee; margin:8px 0 0; }
|
||||
.activity-heading .logs-link { font-size:14px; font-weight:650; color:#eee; }
|
||||
.activity-history { list-style:none; padding:0; margin:0; display:grid; gap:4px; }
|
||||
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
|
||||
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
|
||||
.activity-history li[hidden] { display:none; }
|
||||
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
|
||||
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
|
||||
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
|
||||
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
|
||||
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
|
||||
#engage::before, #engage::after { display:none; }
|
||||
body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||
#engage .engage-label { color:#777; background:none; animation:none; }
|
||||
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
|
||||
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
|
||||
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
|
||||
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
|
||||
#engage.pending .engage-label { color:#5a8753; background:none; }
|
||||
#engage.danger .engage-label { color:#171717; }
|
||||
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
|
||||
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
|
||||
.control-status[hidden] { display:none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.engage-sheen-label { display:none; }
|
||||
}
|
||||
.controls-grid {
|
||||
--joy-size:min(54vh,calc(100vw - 480px),460px);
|
||||
--joy-circle-d:calc(var(--joy-size) * .86);
|
||||
grid-template-columns:var(--joy-size) 112px; gap:24px;
|
||||
justify-content:center; align-content:center;
|
||||
}
|
||||
.control-card { border:0; border-radius:0; box-shadow:none; background:transparent; padding:0; }
|
||||
.control-card header { align-items:center; min-height:20px; }
|
||||
.control-card small, .key-hint { font-size:12px; }
|
||||
.key-hint { background:transparent; }
|
||||
.control-card footer { height:22px; font-size:13px; margin:0; }
|
||||
.vertical-card footer { justify-content:center; text-align:center; }
|
||||
.vertical-card footer b { display:inline-block; width:4ch; text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.joystick-pad,.heave-pad { padding:0; flex:none; }
|
||||
.vertical-control { font-size:11px; gap:4px; }
|
||||
.heave-track { width:58px; border-radius:999px; background:#eaeaea; border-color:#d6d6d6; }
|
||||
.heave-knob { width:76px; height:40px; border-radius:999px; background:linear-gradient(145deg,#737373,#454545); }
|
||||
.heave-fill { left:22px; right:22px; background:#999; }
|
||||
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
|
||||
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
|
||||
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
|
||||
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
|
||||
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
|
||||
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
|
||||
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
|
||||
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }
|
||||
.workspace-topbar { height:40px; min-height:40px; }
|
||||
.window-actions { gap:5px; }
|
||||
.window-actions button { width:42px; height:38px; border-radius:999px; }
|
||||
.window-actions button svg { width:20px; height:20px; }
|
||||
body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
@media (max-height:610px) {
|
||||
.primary-control-zone { grid-template-rows:100px minmax(0,1fr) 52px; }
|
||||
.engage-header-zone { height:100px; }
|
||||
body #engage { height:84px; }
|
||||
.controls-grid { --joy-size:min(49vh,calc(100vw - 480px),420px); }
|
||||
.sidebar-deck { gap:12px; padding-top:14px; padding-bottom:14px; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.activity-history { gap:4px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user