Improve controller input, console, and UI consistency

This commit is contained in:
2026-10-02 08:02:41 +02:00
parent 0093f3f4aa
commit 31f720a316
18 changed files with 1427 additions and 175 deletions
+178 -25
View File
@@ -14,7 +14,7 @@ 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]}
const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
});
}
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)})})}
@@ -22,7 +22,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
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},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
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}))});
@@ -34,7 +154,7 @@ function populatePorts(ports){
if(!p)return;
const old=p.value||'';
p.innerHTML='';
const allList=[];
const seen=new Set();
@@ -84,20 +204,21 @@ function sendAxes(){
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(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())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(!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(!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)});
heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||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(target=0){cancelAnimationFrame(heaveAnim);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+(target-sv)*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(!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)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(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 applyKeys(){if(inputMode!=='keyboard'||!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)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(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(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;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(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset();
@@ -120,9 +241,11 @@ function setFeedback(el,message=''){
function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
if(!el||el.open)return;
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='controller-modal')fillControllerForm();
if(el.id==='link-modal'){
fillLinkForm();
$('#custom-port-status').textContent='';
@@ -393,12 +516,14 @@ function startEngageHold(e){
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%');
syncInputModeControls();
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
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.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');
syncInputModeControls();
}
if(engageButton){
@@ -409,7 +534,7 @@ if(engageButton){
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=()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm');
@@ -417,33 +542,55 @@ if(engageButton){
}
$('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false;
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
const consolePulseTimers={};
function pulseConsole(side){
const dot=$('.'+side+'-dot');
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
}
function renderConsole(){
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
if(signature===consoleSignature)return;
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
}
function setConsoleOpen(open){
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
$('#console').setAttribute('aria-expanded',String(open));
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
}
$('#console').setAttribute('aria-controls','console-dock');
$('#console').setAttribute('aria-expanded','false');
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
$('#console-close').onclick=()=>setConsoleOpen(false);
$('#console-filter').onchange=renderConsole;
$('#console-pause').onclick=()=>{
consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages';
$('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return;
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36;
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36;
const sides=await api('read_console',150);
if(consolePaused||!consoleDialog.open)return;
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…';
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…';
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight;
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight;
}catch(error){
$('#console-state').textContent=`Console unavailable: ${error.message}`;
}
const sides=await withTimeout(api('read_console',150),1500);
if(consolePaused||!consoleOpen)return;
const filter=$('#console-filter').value;
for(const side of ['rx','tx']){
const previous=new Set(consoleCache[side]||[]);
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
}
consoleCache=sides;renderConsole();
$('#console-state').textContent='';$('#console-state').hidden=true;
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
finally{consoleRefreshBusy=false}
}
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750);
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);
@@ -564,6 +711,7 @@ function render(s,confirmed=false){
(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});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
@@ -604,7 +752,7 @@ function render(s,confirmed=false){
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
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)}
@@ -617,11 +765,13 @@ function render(s,confirmed=false){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
$('#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)';
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
$('.battery-card').dataset.low=String(!!b.low);
}else{
$('#battery-title').textContent='—';
$('#battery-percent').textContent='waiting';
$('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
}
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
@@ -800,3 +950,6 @@ if(document.readyState==='loading'){
loadDefaultModel();
waitForBridge();
}
requestAnimationFrame(pollController);
+70
View File
@@ -0,0 +1,70 @@
/* Read-only presentation of the existing local RX/TX logs. */
((root)=>{
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
function parse(line){
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
const [,timestamp,category,message]=match;
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
}
function valueText(value){
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
return typeof value==='object'?JSON.stringify(value):String(value);
}
function makeRow(row,previous){
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
const details=previous||document.createElement('details');
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
let summary=details.querySelector('summary'),time,body,type,fields,raw;
if(!summary){
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
raw=document.createElement('pre');details.append(summary,raw);
}else{
time=summary.querySelector('time');body=summary.querySelector('.console-message');
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
}
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
const values=row.fields??row.payload;
if(typeof values==='object'&&values!==null){
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
}
}else fields.textContent=row.payload;
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
return details;
}
function render(host,lines,filter){
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
const existing=new Map();
for(const node of host.querySelectorAll('details.console-row')){
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
}
if(!rows.length){
if(!host.querySelector('.console-empty')){
const empty=document.createElement('p');empty.className='console-empty';
empty.textContent='No messages';
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
host.replaceChildren(empty);
}
return;
}
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
let cursor=host.firstElementChild;
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
}
root.ConsoleView={parse,render};
if(typeof module!=='undefined')module.exports=root.ConsoleView;
})(typeof window!=='undefined'?window:globalThis);
+53
View File
@@ -0,0 +1,53 @@
/* 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(!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;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+52 -33
View File
@@ -4,11 +4,11 @@
<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=20260930-dialogs-v1">
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
</head>
<body>
<main class="app-shell">
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
@@ -44,12 +44,6 @@
</div>
</div>
<!-- Battery Status -->
<button class="battery-card ghost" data-open="battery-modal">
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
<b><em id="battery-fill"></em></b>
</button>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
@@ -57,13 +51,18 @@
</button>
<div class="deck-status-bar">
<header class="activity-heading">Activity:</header>
<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><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header>
<div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
</div>
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
<span class="battery-copy"><span class="battery-summary"><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i></span>
</button>
</section>
</aside>
@@ -72,6 +71,7 @@
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
@@ -80,6 +80,7 @@
</div>
</div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone">
@@ -98,12 +99,12 @@
<!-- Steering / Throttle Joystick -->
<article class="control-card steer-card">
<header>
<div><small>steer + throttle</small></div>
<span class="key-hint">w a s d</span>
<div title="Steer and throttle · W A S D"></div>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
<canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
</div>
</div>
<footer>
@@ -115,13 +116,13 @@
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card">
<header>
<div><small>vertical</small></div>
<span class="key-hint">r / f</span>
<div title="Vertical thrust · R / F"></div>
</header>
<div class="heave-pad">
<div class="vertical-control">
<span>rise</span>
<div id="heave" class="heave-track" touch-action="none">
<div id="heave" title="Vertical thrust · R / F" class="heave-track" touch-action="none">
<div class="heave-center"></div>
<div id="heave-fill" class="heave-fill"></div>
<div id="heave-knob" class="heave-knob"></div>
@@ -147,21 +148,27 @@
</div>
</section>
</section>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
</aside>
</main>
<div id="toast" class="toast"></div>
<!-- Modals -->
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
<form id="link-form">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
<form id="link-form" title="Baud changes apply on the next connection.">
<section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<p class="note">Baud changes apply on the next connection.</p>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p>
</section>
<section class="settings-section"><h3>Live updates</h3>
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
@@ -169,17 +176,17 @@
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
</div>
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
</section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form>
</dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</h2>
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
@@ -187,8 +194,8 @@
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</p>
<form id="battery-form">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
<p class="form-feedback" role="alert" hidden></p>
@@ -196,20 +203,32 @@
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</h2>
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div>
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script>
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260930-dialogs-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261002-spacing-motion-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+6 -4
View File
@@ -23,7 +23,9 @@
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?'#555':'#353535';ctx.fill();ctx.restore();
const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;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();
}
@@ -40,10 +42,10 @@
function returnToCenter(){easeTo(0,0)}
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||!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('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||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)},easeTo(nextX,nextY){if(activePointer===null)easeTo(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}}};
addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(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}}};
})();
+175 -2
View File
@@ -756,7 +756,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
background: #4e7140;
border-color: #78ad5a;
}
.danger {
.danger {
--engage-progress: 0%;
background: #ad3f38;
color: #ffffff;
@@ -947,7 +947,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
border-radius: 24px;
background: rgba(247, 248, 245, 0.96);
color: #1a1e1f;
box-shadow:
box-shadow:
0 30px 80px -12px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(0, 0, 0, 0.06),
0 10px 24px -6px rgba(0, 0, 0, 0.16);
@@ -1298,3 +1298,176 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
@media (prefers-reduced-motion:reduce) {
.modal[open],.modal.closing { animation:none; }
}
/* Input selection stays separate from vehicle arming and command ownership. */
#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; }
.controls-workspace { container-type:inline-size; }
.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; }
.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; }
.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; }
.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; }
.controller-settings { background:#dedede; }
.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
body[data-input-mode="joystick"] .key-hint { visibility:hidden; }
body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; }
.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); }
.control-toolbar { flex-wrap:wrap; gap:8px 14px; }
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; }
.control-toolbar { min-height:52px; }
.engage-face > span { font-size:clamp(52px,11cqw,90px); }
.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; }
.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; }
.controller-mapping-row { margin:8px 0; }
.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; }
.controller-mapping-row label { display:flex; align-items:center; }
#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; }
#controller-form > label { margin-top:18px; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
/* Read-only local console, not a modal: driving stays available while it is open. */
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; }
.console-dock[hidden] { display:none!important; }
.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; }
.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
.console-dock-header small { font-size:11px; color:#81888d; }
.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; }
.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; }
.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.console-toolbar label { flex:1; min-width:0; }
.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; }
.console-toolbar select { width:100%; }
.console-toolbar button { cursor:pointer; }
.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; }
.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; }
.console-side { display:flex; flex-direction:column; min-height:0; }
.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; }
.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; }
.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; }
.console-side > header .tx-dot { background:#659287; }
.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; }
.console-row { border-bottom:1px solid #e9edef; }
.console-row:last-child { border-bottom:0; }
.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; }
.console-row summary::-webkit-details-marker { display:none; }
.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; }
.console-message { min-width:0; display:block; overflow-wrap:anywhere; }
.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; }
.console-row[data-tone="command"] strong { color:#467a69; }
.console-row[data-tone="warning"] strong { color:#aa664b; }
.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; }
.console-fields i { color:#929a9f; font-style:normal; }
.console-fields b { font-weight:400; color:#45535d; }
.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; }
@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } }
@media (max-width:1000px) {
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr); }
.console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; }
}
/* Three shared sizes: compact 32px, standard 40px, primary 62px. */
:root {
--pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px;
--battery-charge:#63cb85; --battery-low:#e98a7b;
}
.port-row select,.icon-button { height:var(--pill-sm); }
.icon-button { width:var(--pill-sm); }
body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
.input-mode-switch { height:var(--pill-md); padding:4px; }
.input-mode-switch button { height:var(--pill-sm); padding:0 12px; }
.controller-settings { height:var(--pill-md); padding:0 14px; }
.input-mode-switch button:disabled { opacity:.65!important; }
.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; }
.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); }
.control-toolbar .range-pill button { height:var(--pill-md); }
.window-actions button { width:var(--pill-md); height:var(--pill-md); }
.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); }
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; }
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover {
display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md);
padding:0; border:0; border-radius:0; background:transparent; box-shadow:none;
}
.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
.modal-close:hover,.console-close:hover { color:#222; }
.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); }
/* Activity has a heading, compact event history, then explicit actions. */
.telemetry-zone { gap:16px; }
.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; }
.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; }
.activity-history { gap:4px; margin:0 0 10px; }
.activity-actions { display:flex; gap:8px; }
.activity-actions .logs-link {
display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0;
border:0; border-radius:var(--pill-radius); background:#414141; color:#eee;
font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer;
}
.activity-actions .logs-link:hover { background:#4b4b4b; }
.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
.activity-actions .logs-link[aria-expanded="true"] { background:#555; }
/* Battery is the final deck item: text floats above the charge fill. */
.battery-card {
position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg);
min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden;
border:1px solid #565956; border-radius:var(--pill-radius); background:#383c39;
color:#f2f5f2; isolation:isolate;
}
.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; }
.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.25; transition:width .35s ease,background .2s ease; }
.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; }
.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; }
.battery-card small { color:#c3cec5; font-size:11px; line-height:1; }
.battery-card strong { font-size:20px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; }
.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; }
.battery-card[data-low="true"] { border-color:#a87468; }
.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
/* The same slender, rounded scrollbar is used by every scrollable panel. */
*::-webkit-scrollbar { width:8px; height:8px; }
*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; }
*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; }
*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; }
*::-webkit-scrollbar-button { display:none; width:0; height:0; }
.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; }
.modal { scrollbar-gutter:stable; }
@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } }
@media (max-height:610px) { .telemetry-zone { gap:12px; } }
/* Shared panel geometry and quiet, presentational motion. Never ease command data. */
:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); }
.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); }
.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); }
.deck-connection { padding-bottom:var(--ui-gap); }
.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); }
.port-row select,.port-row .icon-button { height:var(--pill-md); }
.port-row .icon-button { width:var(--pill-md); }
.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
.activity-actions .logs-link { min-width:0; width:100%; }
.console-dock-header h2 { margin:0; }
.console-toolbar { margin-bottom:var(--ui-gap); }
.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; }
.console-side > header { padding-left:0; padding-right:0; }
.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; }
.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; }
.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; }
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) {
transition-property:background-color,border-color,color,opacity,box-shadow,filter;
transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease);
}
/* Keep pointer positions immediate; the existing conditioning owns motor easing. */
.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); }
.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; }
@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } }
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
.controller-status { transition:opacity var(--ui-duration) ease-out; }
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }