Redesign touch controls and engagement interaction

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