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

Battery Settings

configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
- - - + + + diff --git a/SUB Control/web/joystick-3d.js b/SUB Control/web/joystick-3d.js index 0682c2b..f481c46 100644 --- a/SUB Control/web/joystick-3d.js +++ b/SUB Control/web/joystick-3d.js @@ -1,317 +1,42 @@ -/* joystick-3d.js – Clean Canvas joystick with tactile press animation, - expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */ - +/* Visual joystick. Control transport remains in app.js through setJoyControl. */ (() => { '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'); - if (!host) return; - - let canvas = document.getElementById('joystick-canvas'); - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.id = 'joystick-canvas'; - host.appendChild(canvas); + function geometry(){ + 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))}; } + 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 = - 'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;'; + ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eef1ee';ctx.fill();ctx.strokeStyle='#cfd5d1';ctx.lineWidth=1.5;ctx.stroke(); + ctx.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; - let nx = 0, ny = 0; // normalised knob position: -1 to +1 - let dragging = false; - let animId = 0; - 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(); + ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3; + ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#4d5652':'#303936';ctx.fill();ctx.restore(); + ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.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(); } - 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; - - function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) { - ctx.save(); - ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif'; - ctx.fillStyle = '#656f6c'; - 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(); } - }; + 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)}); + canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)}); + canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)}); + addEventListener('resize',resize);resize(); + window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},getState(){return {x,y,active:activePointer!==null}}}; })(); diff --git a/SUB Control/web/styles.css b/SUB Control/web/styles.css index 8334752..6a7e7b3 100644 --- a/SUB Control/web/styles.css +++ b/SUB Control/web/styles.css @@ -615,69 +615,83 @@ body[data-app-state="link_degraded"] .status-line .status-dot { text-transform: uppercase; letter-spacing: .08em; } +@property --engage-progress { + syntax: ""; + inherits: false; + initial-value: 0%; +} .engage-button { - min-width: 140px; - height: 32px; - padding: 0 20px; - font-size: 11px; - font-weight: 500; - border-radius: 999px; + --engage-progress: 0%; + min-width: 166px; + height: 38px; + padding: 0 24px; + border-radius: 10px; position: relative; overflow: hidden; 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:hover { background: #d7dcd5; } .arm { - background: #eef3e5; - color: #24351a; - border: 1px solid rgba(157, 232, 72, 0.4); + background: #343a39; + color: #f7f8f6; + border-color: #242927; 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 { - background: #c9f277 !important; - color: #14200d !important; - transform: scale(1.04); + --engage-progress: 100%; + background: #4e7140; + border-color: #78ad5a; } .danger { - background: #f8deda; - color: #7a2822; - border: 1px solid rgba(240, 149, 139, 0.4); + --engage-progress: 0%; + background: #ad3f38; + color: #ffffff; + border: 1px solid #8e302b; } +.danger .engage-label { color:#fff; background:none; } +.danger .engage-progress-label { opacity:0; } .danger:hover { - background: #f4cac4; - border-color: rgba(240, 149, 139, 0.7); + background: #bd4740; + border-color: #8e302b; } /* ── Main Controls Grid ── */ .controls-grid { - --joy-size: min(34vh, 230px); + --joy-size: min(46vh, 320px); --joy-circle-d: calc(var(--joy-size) * 0.90); display: grid; grid-template-columns: minmax(300px, 1fr) 175px; @@ -738,8 +752,8 @@ body[data-app-state="link_degraded"] .status-line .status-dot { position: relative; width: var(--size); height: var(--size); - min-width: 165px; - min-height: 165px; + min-width: 220px; + min-height: 220px; margin: auto; border: 0; background: transparent; @@ -763,7 +777,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot { } .heave-track { position: relative; - width: 36px; + width: 48px; height: var(--joy-circle-d); border-radius: 18px; background: #eaeee8; @@ -774,19 +788,20 @@ body[data-app-state="link_degraded"] .status-line .status-dot { .heave-track::before { display: none !important; } .heave-center { position: absolute; - left: 6px; - right: 6px; + left: 7px; + right: 7px; top: 50%; - height: 1px; - background: #b2b9b4; + height: 3px; + border-radius: 2px; + background: #75807b; } .heave-fill { position: absolute; - left: 12px; - right: 12px; + left: 18px; + right: 18px; top: 50%; height: 0; - background: rgba(188, 159, 235, 0.4); + background: #87958e; border-radius: 3px; } .heave-knob { @@ -794,8 +809,8 @@ body[data-app-state="link_degraded"] .status-line .status-dot { left: 50%; top: 50%; translate: -50% -50%; - width: 48px; - height: 27px; + width: 64px; + height: 34px; border-radius: 14px; background: linear-gradient(145deg, #687271, #3c4443); border: 1px solid #d7dedc;