Redesign touch controls and engagement interaction
This commit is contained in:
+33
-86
@@ -57,15 +57,17 @@ function updateReadouts(){ if(appState!=='armed'){$('#surge-readout').textConten
|
|||||||
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
|
|
||||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||||
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();}
|
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();}
|
||||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||||
heave.addEventListener('pointerdown',e=>{if(appState!=='armed')return;heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
|
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||||
|
heave.addEventListener('pointerdown',e=>{if(appState!=='armed'||heavePointer!==null)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||||
|
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
|
||||||
|
|
||||||
function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave();}
|
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'&&engageButton?.classList.contains('arm')&&!e.repeat)startEngageHold();return;}if(e.key==='F1'){e.preventDefault();if(appState==='connected_disarmed'&&!engageButton?.classList.contains('arm')&&!e.repeat)engageButton?.click();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
|
addEventListener('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(!heaveDrag)easeHeave()});
|
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave()});
|
||||||
|
|
||||||
function openModal(id){
|
function openModal(id){
|
||||||
const el=typeof id==='string'?document.getElementById(id):id;
|
const el=typeof id==='string'?document.getElementById(id):id;
|
||||||
@@ -234,67 +236,50 @@ if(customPortBtn&&customPortInput){
|
|||||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||||
}
|
}
|
||||||
const engageButton=$('#engage');
|
const engageButton=$('#engage');
|
||||||
let engageHoldTimer=null, engageStart=0, engageCharged=false, settingMode=false;
|
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||||
const ENGAGE_HOLD_MS=1000;
|
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||||
|
const ENGAGE_HOLD_MS=1400;
|
||||||
|
|
||||||
function isManualMode(s){
|
function isManualMode(s){
|
||||||
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
|
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
|
||||||
return m==='MANUAL';
|
return m==='MANUAL';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEngageProgress(){
|
function applyEngageVisual(next){
|
||||||
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')){
|
if(!engageButton||engageVisualState===next)return;
|
||||||
stopEngageHold(true);
|
const previous=engageVisualState;engageVisualState=next;clearTimeout(engageTransitionTimer);
|
||||||
return;
|
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();
|
||||||
const elapsed=performance.now()-engageStart;
|
}
|
||||||
const progress=Math.min(1,elapsed/ENGAGE_HOLD_MS);
|
async function completeEngage(){
|
||||||
engageButton.style.setProperty('--arm-progress',`${progress*100}%`);
|
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%');
|
||||||
if(progress>=1){
|
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)}
|
||||||
engageCharged=true;
|
}
|
||||||
stopEngageHold(false);
|
function updateEngageProgress(now){
|
||||||
engageButton.classList.add('charged');
|
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return cancelEngageHold(false);
|
||||||
setTimeout(()=>engageButton.classList.remove('charged'),600);
|
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);
|
||||||
if(demoActive){updateDemo({armed:true,app_state:'armed',vehicle:'DEMO • ARMED',status:'Demo: Arm confirmed'});toast('Demo vehicle armed')}
|
|
||||||
else api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEngageHold(e){
|
function startEngageHold(e){
|
||||||
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return;
|
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')||engageStart)return;
|
||||||
if(engageStart||engageCharged)return;
|
|
||||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||||
engageStart=performance.now();
|
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||||
engageCharged=false;
|
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||||
engageButton.classList.add('charging');
|
|
||||||
engageButton.style.setProperty('--arm-progress','0%');
|
|
||||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||||
}
|
}
|
||||||
|
function cancelEngageHold(showHint=true){
|
||||||
function stopEngageHold(wasCancelled=true){
|
|
||||||
if(!engageStart)return;
|
if(!engageStart)return;
|
||||||
const elapsed=performance.now()-engageStart;
|
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');
|
||||||
cancelAnimationFrame(engageHoldTimer);
|
|
||||||
engageHoldTimer=null;
|
|
||||||
engageStart=0;
|
|
||||||
engageButton.classList.remove('charging');
|
|
||||||
engageButton.style.setProperty('--arm-progress','0%');
|
|
||||||
if(wasCancelled&&!engageCharged&&elapsed<ENGAGE_HOLD_MS&&elapsed>70){
|
|
||||||
toast('Hold for 1s to Engage vehicle');
|
|
||||||
}
|
|
||||||
engageCharged=false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(engageButton){
|
if(engageButton){
|
||||||
engageButton.addEventListener('pointerdown',startEngageHold);
|
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||||
engageButton.addEventListener('pointerup',()=>stopEngageHold(true));
|
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
engageButton.addEventListener('pointerleave',()=>stopEngageHold(true));
|
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
engageButton.addEventListener('pointercancel',()=>stopEngageHold(true));
|
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
|
||||||
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
|
|
||||||
engageButton.onclick=async()=>{
|
engageButton.onclick=async()=>{
|
||||||
|
if(suppressEngageClick){suppressEngageClick=false;return}
|
||||||
if(appState==='armed'){
|
if(appState==='armed'){
|
||||||
try{
|
try{
|
||||||
if(demoActive)updateDemo({armed:false,app_state:'connected_disarmed',vehicle:'DEMO • DISARMED',status:'Demo: Disarm confirmed'});
|
if(demoActive)updateDemo({armed:false,app_state:'connected_disarmed',vehicle:'DEMO • DISARMED',status:'Demo: Disarm confirmed'});
|
||||||
@@ -303,18 +288,6 @@ if(engageButton){
|
|||||||
}catch(e){
|
}catch(e){
|
||||||
toast(e.message,true);
|
toast(e.message,true);
|
||||||
}
|
}
|
||||||
}else if(appState==='connected_disarmed'&&!engageButton.classList.contains('arm')){
|
|
||||||
try{
|
|
||||||
settingMode=true;
|
|
||||||
const lbl=$('#engage-label');
|
|
||||||
if(lbl)lbl.textContent='Setting mode…';
|
|
||||||
if(demoActive)updateDemo({mode:'MANUAL',status:'Demo: Manual mode confirmed'});
|
|
||||||
else await api('command','manual');
|
|
||||||
toast('Manual mode requested');
|
|
||||||
}catch(e){
|
|
||||||
settingMode=false;
|
|
||||||
toast(e.message,true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -351,33 +324,7 @@ function render(s){
|
|||||||
const engageBtn=$('#engage');
|
const engageBtn=$('#engage');
|
||||||
const engageLabel=$('#engage-label');
|
const engageLabel=$('#engage-label');
|
||||||
if(engageBtn&&engageLabel){
|
if(engageBtn&&engageLabel){
|
||||||
if(appState==='armed'){
|
applyEngageVisual(appState==='armed'?'disarm':appState==='connected_disarmed'?'engage':'disabled');
|
||||||
settingMode=false;
|
|
||||||
engageBtn.disabled=false;
|
|
||||||
engageBtn.className='button danger';
|
|
||||||
engageBtn.title='Disarm (F3)';
|
|
||||||
engageLabel.textContent='Disarm';
|
|
||||||
}else if(appState==='connected_disarmed'){
|
|
||||||
const manualConfirmed=isManualMode(s);
|
|
||||||
if(manualConfirmed){
|
|
||||||
settingMode=false;
|
|
||||||
engageBtn.disabled=false;
|
|
||||||
engageBtn.className='button arm';
|
|
||||||
engageBtn.title='Hold to Engage (F2)';
|
|
||||||
engageLabel.textContent='Hold to Engage';
|
|
||||||
}else{
|
|
||||||
engageBtn.disabled=false;
|
|
||||||
engageBtn.className='button subtle';
|
|
||||||
engageBtn.title='Set Manual Mode (F1)';
|
|
||||||
engageLabel.textContent=settingMode?'Setting mode…':'Engage';
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
settingMode=false;
|
|
||||||
engageBtn.disabled=true;
|
|
||||||
engageBtn.className='button subtle';
|
|
||||||
engageBtn.title='Engage';
|
|
||||||
engageLabel.textContent='Engage';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const motorBtn=$('[data-open="motors-modal"]');
|
const motorBtn=$('[data-open="motors-modal"]');
|
||||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Sub Control</title>
|
<title>Sub Control</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=20260826-stage2-fix1">
|
<link rel="stylesheet" href="styles.css?v=20260826-controls-v2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
@@ -97,9 +97,9 @@
|
|||||||
<div class="engage-header-zone">
|
<div class="engage-header-zone">
|
||||||
<span class="zone-label">vehicle control</span>
|
<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" title="Engage (F2)">
|
||||||
<span class="arm-copy">
|
<span class="engage-face">
|
||||||
<span id="engage-label" class="arm-label">Engage</span>
|
<span id="engage-label" class="engage-label">ENGAGE</span>
|
||||||
<span class="arm-holding-label">Engaging…</span>
|
<span class="engage-progress-label" aria-hidden="true">ENGAGE</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,8 +156,8 @@
|
|||||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||||
<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>
|
<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-stage2-fix1"></script>
|
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||||
<script src="joystick-3d.js?v=20260826-stage2-fix1"></script>
|
<script src="joystick-3d.js?v=20260826-controls-v2"></script>
|
||||||
<script src="app.js?v=20260826-stage2-fix1"></script>
|
<script src="app.js?v=20260826-controls-v2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+32
-307
@@ -1,317 +1,42 @@
|
|||||||
/* joystick-3d.js – Clean Canvas joystick with tactile press animation,
|
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
|
||||||
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
|
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
|
||||||
|
if(!host||!canvas)return;
|
||||||
|
const ctx=canvas.getContext('2d');
|
||||||
|
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
|
||||||
|
|
||||||
const host = document.getElementById('joystick');
|
function geometry(){
|
||||||
if (!host) return;
|
const radius=Math.min(width,height)*.43;
|
||||||
|
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
|
||||||
let canvas = document.getElementById('joystick-canvas');
|
|
||||||
if (!canvas) {
|
|
||||||
canvas = document.createElement('canvas');
|
|
||||||
canvas.id = 'joystick-canvas';
|
|
||||||
host.appendChild(canvas);
|
|
||||||
}
|
}
|
||||||
|
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
|
||||||
|
function draw(){
|
||||||
|
if(!width||!height)return;
|
||||||
|
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);
|
||||||
|
|
||||||
canvas.style.cssText =
|
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();
|
||||||
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;';
|
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();
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
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()}
|
||||||
|
|
||||||
let W = 0, H = 0, dpr = 1;
|
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||||
let nx = 0, ny = 0; // normalised knob position: -1 to +1
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#4d5652':'#303936';ctx.fill();ctx.restore();
|
||||||
let dragging = false;
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||||
let animId = 0;
|
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||||
let pressAnimId = 0;
|
|
||||||
let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const r = host.getBoundingClientRect();
|
|
||||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
W = r.width;
|
|
||||||
H = r.height;
|
|
||||||
canvas.width = Math.round(W * dpr);
|
|
||||||
canvas.height = Math.round(H * dpr);
|
|
||||||
draw();
|
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', resize);
|
function publish(){window.setJoyControl?.(x,y)}
|
||||||
|
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
|
||||||
|
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
|
||||||
|
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()}
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
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('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||||
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) {
|
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||||
ctx.save();
|
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||||
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif';
|
addEventListener('resize',resize);resize();
|
||||||
ctx.fillStyle = '#656f6c';
|
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},getState(){return {x,y,active:activePointer!==null}}};
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
const charWidths = [];
|
|
||||||
let totalWidth = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const w = ctx.measureText(text[i]).width + 1.8;
|
|
||||||
charWidths.push(w);
|
|
||||||
totalWidth += w;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalAngle = totalWidth / radius;
|
|
||||||
|
|
||||||
if (!flip) {
|
|
||||||
let currentAngle = centerAngle - totalAngle / 2;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const charAngle = charWidths[i] / radius;
|
|
||||||
const midAngle = currentAngle + charAngle / 2;
|
|
||||||
const x = cx + radius * Math.cos(midAngle);
|
|
||||||
const y = cy + radius * Math.sin(midAngle);
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.rotate(midAngle + Math.PI / 2);
|
|
||||||
ctx.fillText(text[i], 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
currentAngle += charAngle;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let currentAngle = centerAngle + totalAngle / 2;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const charAngle = charWidths[i] / radius;
|
|
||||||
const midAngle = currentAngle - charAngle / 2;
|
|
||||||
const x = cx + radius * Math.cos(midAngle);
|
|
||||||
const y = cy + radius * Math.sin(midAngle);
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.rotate(midAngle - Math.PI / 2);
|
|
||||||
ctx.fillText(text[i], 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
currentAngle -= charAngle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
if (!W || !H) return;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
|
|
||||||
const cx = W / 2;
|
|
||||||
const cy = H / 2;
|
|
||||||
const R = Math.min(W, H) * 0.45;
|
|
||||||
const baseKnobR = R * 0.22; // 20% larger knob
|
|
||||||
const knobR = baseKnobR * pressScale;
|
|
||||||
const travel = R * 0.58;
|
|
||||||
const dist = Math.hypot(nx, ny);
|
|
||||||
|
|
||||||
/* ── 1. SPHERICAL BOWL BASE ── */
|
|
||||||
ctx.save();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, R, 0, TAU);
|
|
||||||
ctx.clip();
|
|
||||||
|
|
||||||
// Base smooth light background (clean, no blue edge gradient)
|
|
||||||
const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R);
|
|
||||||
bg.addColorStop(0, '#ffffff');
|
|
||||||
bg.addColorStop(0.55, '#f7f9f8');
|
|
||||||
bg.addColorStop(0.85, '#edf1f0');
|
|
||||||
bg.addColorStop(1, '#e3e8e7');
|
|
||||||
ctx.fillStyle = bg;
|
|
||||||
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
|
||||||
|
|
||||||
// 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65)
|
|
||||||
if (dist > 0.65) {
|
|
||||||
const redFactor = Math.min(1, (dist - 0.65) / 0.35);
|
|
||||||
const angle = Math.atan2(ny, nx);
|
|
||||||
const edgeX = cx + R * Math.cos(angle);
|
|
||||||
const edgeY = cy + R * Math.sin(angle);
|
|
||||||
const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85);
|
|
||||||
redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`);
|
|
||||||
redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`);
|
|
||||||
redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)');
|
|
||||||
ctx.fillStyle = redGrad;
|
|
||||||
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Straight vertical and horizontal axis lines
|
|
||||||
ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cx, cy - R * 0.85);
|
|
||||||
ctx.lineTo(cx, cy + R * 0.85);
|
|
||||||
ctx.moveTo(cx - R * 0.85, cy);
|
|
||||||
ctx.lineTo(cx + R * 0.85, cy);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// ── Curved Lowercase Directional Labels ──
|
|
||||||
const labelR = R * 0.84;
|
|
||||||
drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false);
|
|
||||||
drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true);
|
|
||||||
drawCurvedText('turn left', cx, cy, labelR, Math.PI, false);
|
|
||||||
drawCurvedText('turn right', cx, cy, labelR, 0, false);
|
|
||||||
|
|
||||||
ctx.restore(); // un-clip
|
|
||||||
|
|
||||||
// Outer rim border
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, R, 0, TAU);
|
|
||||||
ctx.strokeStyle = '#cdd6d4';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
/* ── 2. SOFT KNOB SHADOW ── */
|
|
||||||
const kx = cx + nx * travel;
|
|
||||||
const ky = cy + ny * travel;
|
|
||||||
|
|
||||||
const shadowY = ky + 2.5;
|
|
||||||
const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25);
|
|
||||||
sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)');
|
|
||||||
sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)');
|
|
||||||
sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU);
|
|
||||||
ctx.fillStyle = sg;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
/* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(kx, ky);
|
|
||||||
|
|
||||||
// Lighter, clean metallic/slate gradient
|
|
||||||
const kg = ctx.createLinearGradient(0, -knobR, 0, knobR);
|
|
||||||
kg.addColorStop(0, '#687271');
|
|
||||||
kg.addColorStop(0.45, '#515958');
|
|
||||||
kg.addColorStop(1, '#3c4443');
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, knobR, 0, TAU);
|
|
||||||
ctx.fillStyle = kg;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Clean 1px top highlight bevel
|
|
||||||
const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR);
|
|
||||||
bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)');
|
|
||||||
bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)');
|
|
||||||
bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)');
|
|
||||||
bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)');
|
|
||||||
ctx.strokeStyle = bevel;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Outer light edge border (matching vertical knob)
|
|
||||||
ctx.strokeStyle = '#d7dedc';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Subtle cyan light glow ONLY when actively holding/dragging
|
|
||||||
if (dragging) {
|
|
||||||
const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15);
|
|
||||||
gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)');
|
|
||||||
gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)');
|
|
||||||
gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)');
|
|
||||||
gg.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, knobR, 0, TAU);
|
|
||||||
ctx.fillStyle = gg;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore(); // un-knob translate
|
|
||||||
ctx.restore(); // top-level save
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPos(x, y) {
|
|
||||||
const len = Math.hypot(x, y);
|
|
||||||
if (len > 1) { x /= len; y /= len; }
|
|
||||||
nx = x;
|
|
||||||
ny = y;
|
|
||||||
draw();
|
|
||||||
if (window.setJoyControl) window.setJoyControl(nx, ny);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointerToNorm(e) {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
const cx = r.width / 2;
|
|
||||||
const cy = r.height / 2;
|
|
||||||
const travel = Math.min(r.width, r.height) * 0.45 * 0.58;
|
|
||||||
const px = e.clientX - r.left;
|
|
||||||
const py = e.clientY - r.top;
|
|
||||||
setPos((px - cx) / travel, (py - cy) / travel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tactile Press Animation (matching vertical knob) ── */
|
|
||||||
function animatePress(toPressed) {
|
|
||||||
cancelAnimationFrame(pressAnimId);
|
|
||||||
const startScale = pressScale;
|
|
||||||
const targetScale = toPressed ? 0.93 : 1.0;
|
|
||||||
const duration = toPressed ? 110 : 450;
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
function step(now) {
|
|
||||||
const p = Math.min(1, (now - startTime) / duration);
|
|
||||||
const curve = toPressed
|
|
||||||
? p * (2 - p)
|
|
||||||
: 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35;
|
|
||||||
|
|
||||||
pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p);
|
|
||||||
if (!toPressed) {
|
|
||||||
pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p);
|
|
||||||
}
|
|
||||||
draw();
|
|
||||||
|
|
||||||
if (p < 1) {
|
|
||||||
pressAnimId = requestAnimationFrame(step);
|
|
||||||
} else {
|
|
||||||
pressScale = targetScale;
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pressAnimId = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeBack() {
|
|
||||||
cancelAnimationFrame(animId);
|
|
||||||
const sx = nx, sy = ny, t0 = performance.now();
|
|
||||||
(function step(now) {
|
|
||||||
const p = Math.min(1, (now - t0) / 320);
|
|
||||||
const s = p * p * (3 - 2 * p);
|
|
||||||
nx = sx * (1 - s);
|
|
||||||
ny = sy * (1 - s);
|
|
||||||
draw();
|
|
||||||
if (window.setJoyControl) window.setJoyControl(nx, ny);
|
|
||||||
if (p < 1) animId = requestAnimationFrame(step);
|
|
||||||
})(t0);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown', e => {
|
|
||||||
dragging = true;
|
|
||||||
canvas.style.cursor = 'grabbing';
|
|
||||||
cancelAnimationFrame(animId);
|
|
||||||
animatePress(true);
|
|
||||||
canvas.setPointerCapture(e.pointerId);
|
|
||||||
pointerToNorm(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', e => {
|
|
||||||
if (dragging) pointerToNorm(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('pointerup', () => {
|
|
||||||
dragging = false;
|
|
||||||
canvas.style.cursor = 'grab';
|
|
||||||
animatePress(false);
|
|
||||||
easeBack();
|
|
||||||
});
|
|
||||||
canvas.addEventListener('pointercancel', () => {
|
|
||||||
dragging = false;
|
|
||||||
canvas.style.cursor = 'grab';
|
|
||||||
animatePress(false);
|
|
||||||
easeBack();
|
|
||||||
});
|
|
||||||
|
|
||||||
resize();
|
|
||||||
|
|
||||||
window.joystick3D = {
|
|
||||||
setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); },
|
|
||||||
easeBack() { if (!dragging) easeBack(); }
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+70
-55
@@ -615,69 +615,83 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .08em;
|
letter-spacing: .08em;
|
||||||
}
|
}
|
||||||
|
@property --engage-progress {
|
||||||
|
syntax: "<percentage>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0%;
|
||||||
|
}
|
||||||
.engage-button {
|
.engage-button {
|
||||||
min-width: 140px;
|
--engage-progress: 0%;
|
||||||
height: 32px;
|
min-width: 166px;
|
||||||
padding: 0 20px;
|
height: 38px;
|
||||||
font-size: 11px;
|
padding: 0 24px;
|
||||||
font-weight: 500;
|
border-radius: 10px;
|
||||||
border-radius: 999px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
background: #343a39;
|
||||||
|
border: 1px solid #242927;
|
||||||
|
color: #f7f8f6;
|
||||||
|
transition: --engage-progress .28s ease-out, background-color .24s ease, border-color .24s ease, color .2s ease, transform .16s ease;
|
||||||
}
|
}
|
||||||
|
.engage-button::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: var(--engage-progress);
|
||||||
|
background: rgba(139, 218, 83, .18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
|
||||||
|
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
|
||||||
|
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
|
||||||
|
.engage-label {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
|
||||||
|
background-size: 250% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
}
|
||||||
|
.arm:not(.charging):not(.state-transition) .engage-label { animation: engage-shimmer 2.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;
|
||||||
|
}
|
||||||
|
.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} }
|
||||||
.subtle { background: #e2e6e1; color: #28302c; border: 1px solid rgba(0, 0, 0, 0.08); }
|
.subtle { background: #e2e6e1; color: #28302c; border: 1px solid rgba(0, 0, 0, 0.08); }
|
||||||
.subtle:hover { background: #d7dcd5; }
|
.subtle:hover { background: #d7dcd5; }
|
||||||
|
|
||||||
.arm {
|
.arm {
|
||||||
background: #eef3e5;
|
background: #343a39;
|
||||||
color: #24351a;
|
color: #f7f8f6;
|
||||||
border: 1px solid rgba(157, 232, 72, 0.4);
|
border-color: #242927;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.arm::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
width: var(--arm-progress, 0%);
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(90deg, #c9f277, #9de848);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: width 0.04s linear;
|
|
||||||
}
|
|
||||||
.arm-copy {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
.arm-copy > span {
|
|
||||||
grid-area: 1 / 1;
|
|
||||||
transition: opacity 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
.arm-holding-label { opacity: 0; transform: translateY(3px); }
|
|
||||||
.arm.charging .arm-label { opacity: 0; transform: translateY(-3px); }
|
|
||||||
.arm.charging .arm-holding-label { opacity: 1; transform: translateY(0); }
|
|
||||||
.arm.charged {
|
.arm.charged {
|
||||||
background: #c9f277 !important;
|
--engage-progress: 100%;
|
||||||
color: #14200d !important;
|
background: #4e7140;
|
||||||
transform: scale(1.04);
|
border-color: #78ad5a;
|
||||||
}
|
}
|
||||||
.danger {
|
.danger {
|
||||||
background: #f8deda;
|
--engage-progress: 0%;
|
||||||
color: #7a2822;
|
background: #ad3f38;
|
||||||
border: 1px solid rgba(240, 149, 139, 0.4);
|
color: #ffffff;
|
||||||
|
border: 1px solid #8e302b;
|
||||||
}
|
}
|
||||||
|
.danger .engage-label { color:#fff; background:none; }
|
||||||
|
.danger .engage-progress-label { opacity:0; }
|
||||||
.danger:hover {
|
.danger:hover {
|
||||||
background: #f4cac4;
|
background: #bd4740;
|
||||||
border-color: rgba(240, 149, 139, 0.7);
|
border-color: #8e302b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main Controls Grid ── */
|
/* ── Main Controls Grid ── */
|
||||||
.controls-grid {
|
.controls-grid {
|
||||||
--joy-size: min(34vh, 230px);
|
--joy-size: min(46vh, 320px);
|
||||||
--joy-circle-d: calc(var(--joy-size) * 0.90);
|
--joy-circle-d: calc(var(--joy-size) * 0.90);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(300px, 1fr) 175px;
|
grid-template-columns: minmax(300px, 1fr) 175px;
|
||||||
@@ -738,8 +752,8 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
min-width: 165px;
|
min-width: 220px;
|
||||||
min-height: 165px;
|
min-height: 220px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -763,7 +777,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
}
|
}
|
||||||
.heave-track {
|
.heave-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 36px;
|
width: 48px;
|
||||||
height: var(--joy-circle-d);
|
height: var(--joy-circle-d);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #eaeee8;
|
background: #eaeee8;
|
||||||
@@ -774,19 +788,20 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
.heave-track::before { display: none !important; }
|
.heave-track::before { display: none !important; }
|
||||||
.heave-center {
|
.heave-center {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 6px;
|
left: 7px;
|
||||||
right: 6px;
|
right: 7px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 1px;
|
height: 3px;
|
||||||
background: #b2b9b4;
|
border-radius: 2px;
|
||||||
|
background: #75807b;
|
||||||
}
|
}
|
||||||
.heave-fill {
|
.heave-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px;
|
left: 18px;
|
||||||
right: 12px;
|
right: 18px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 0;
|
height: 0;
|
||||||
background: rgba(188, 159, 235, 0.4);
|
background: #87958e;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.heave-knob {
|
.heave-knob {
|
||||||
@@ -794,8 +809,8 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
translate: -50% -50%;
|
translate: -50% -50%;
|
||||||
width: 48px;
|
width: 64px;
|
||||||
height: 27px;
|
height: 34px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(145deg, #687271, #3c4443);
|
background: linear-gradient(145deg, #687271, #3c4443);
|
||||||
border: 1px solid #d7dedc;
|
border: 1px solid #d7dedc;
|
||||||
|
|||||||
Reference in New Issue
Block a user