'use client'; import { useState, useEffect, useRef } from 'react'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // KONFIGURASI const RECORD_INTERVAL_MS = 50; const DIRECTION_BRAKE_MS = 600; const MAX_STEP_TIMEOUT_MS = 15000; const LATENCY_COMPENSATION_TICKS = 3; // TYPE type Steering = { x: number; y: number; }; type MotionType = | 'FORWARD' | 'REVERSE' | 'TURN_LEFT' | 'TURN_RIGHT' | 'STOP'; type TrajectoryStep = { x: number; y: number; target_ticks_l: number; target_ticks_r: number; motion?: MotionType; sample_ms?: number; }; export default function RobotDashboard() { const PI_IP = '192.168.0.105'; const streamUrl = `http://${PI_IP}:8080/stream`; const [controlMode, setControlMode] = useState('touch'); const [gamepadStatus, setGamepadStatus] = useState('Menunggu Gamepad...'); const [steering, setSteering] = useState({ x: 0, y: 0 }); const [ticks, setTicks] = useState({ l: 0, r: 0 }); const [isRecordingUI, setIsRecordingUI] = useState(false); const [isPlayingUI, setIsPlayingUI] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const currentTicksRef = useRef({ l: 0, r: 0 }); const lastRecordedTicksRef = useRef({ l: 0, r: 0 }); const activeMotionRef = useRef('STOP'); const lastCommandRef = useRef({ x: 0, y: 0 }); const isRecordingRef = useRef(false); const isPlayingRef = useRef(false); const isSavingRef = useRef(false); const trajectoryRef = useRef([]); const prevBtnsRef = useRef({ A: false, B: false, Y: false, X: false }); const wsRef = useRef(null); const keysRef = useRef({ up: false, down: false, left: false, right: false, q: false, e: false }); const touchStateRef = useRef({ turn: 0, throttle: 0, pan: 0 }); const sendCommand = (x: number, y: number, pan: number = 0) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ x, y, pan })); } }; const stopRobot = () => { setSteering({ x: 0, y: 0 }); sendCommand(0, 0, touchStateRef.current.pan); }; const getMotionType = (x: number, y: number): MotionType => { const DEADZONE = 0.10; const hasX = Math.abs(x) > DEADZONE; const hasY = Math.abs(y) > DEADZONE; if (!hasX && !hasY) return 'STOP'; if (hasY) return y > 0 ? 'FORWARD' : 'REVERSE'; return x > 0 ? 'TURN_RIGHT' : 'TURN_LEFT'; }; const executePlayback = async (reverse: boolean = false) => { if (isPlayingRef.current || isSavingRef.current) return; isPlayingRef.current = true; setIsPlayingUI(true); console.log(`Memulai Playback ${reverse ? 'REVERSE' : 'FORWARD'}...`); try { const res = await fetch(`/api/trajectory?_t=${Date.now()}`, { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP Error ${res.status}`); const data = (await res.json()) as TrajectoryStep[]; if (!Array.isArray(data) || data.length === 0) { alert('Data perjalanan kosong! Rekam rute terlebih dahulu.'); return; } const routeData = reverse ? [...data].reverse() : data; let lastActiveMotion: MotionType = 'STOP'; for (let i = 0; i < routeData.length; i++) { if (!isPlayingRef.current) break; const step = routeData[i]; const targetX = reverse ? -step.x : step.x; const targetY = reverse ? -step.y : step.y; const currentMotion = getMotionType(targetX, targetY); if (currentMotion !== 'STOP' && lastActiveMotion !== 'STOP') { const isReversal = (lastActiveMotion === 'FORWARD' && currentMotion === 'REVERSE') || (lastActiveMotion === 'REVERSE' && currentMotion === 'FORWARD') || (lastActiveMotion === 'TURN_LEFT' && currentMotion === 'TURN_RIGHT') || (lastActiveMotion === 'TURN_RIGHT' && currentMotion === 'TURN_LEFT'); if (isReversal) { stopRobot(); await sleep(DIRECTION_BRAKE_MS); if (!isPlayingRef.current) break; } } if (currentMotion !== 'STOP') lastActiveMotion = currentMotion; setSteering({ x: targetX, y: targetY }); sendCommand(targetX, targetY, touchStateRef.current.pan); const startL = currentTicksRef.current.l; const startR = currentTicksRef.current.r; const targetL = Math.max(0, Number(step.target_ticks_l || 0)); const targetR = Math.max(0, Number(step.target_ticks_r || 0)); const compTargetL = Math.max(0, targetL - LATENCY_COMPENSATION_TICKS); const compTargetR = Math.max(0, targetR - LATENCY_COMPENSATION_TICKS); if (targetL === 0 && targetR === 0) { await sleep(RECORD_INTERVAL_MS); continue; } const stepStartTime = Date.now(); while (isPlayingRef.current) { const currentDeltaL = Math.abs(currentTicksRef.current.l - startL); const currentDeltaR = Math.abs(currentTicksRef.current.r - startR); let stepFinished = false; if (targetL > targetR) { stepFinished = currentDeltaL >= compTargetL; } else if (targetR > targetL) { stepFinished = currentDeltaR >= compTargetR; } else { stepFinished = currentDeltaL >= compTargetL || currentDeltaR >= compTargetR; } if (stepFinished) break; if (Date.now() - stepStartTime > MAX_STEP_TIMEOUT_MS) { console.warn(`Timeout step ${i}: Robot tersangkut.`); break; } await sleep(10); } } console.log('Playback selesai!'); } catch (error) { console.error('Gagal playback:', error); } finally { stopRobot(); isPlayingRef.current = false; setIsPlayingUI(false); } }; const forceScanGamepad = () => { if (typeof navigator === 'undefined' || !navigator.getGamepads) return; const gamepads = navigator.getGamepads(); let activeGp: Gamepad | null = null; for (let i = 0; i < gamepads.length; i++) { if (gamepads[i]) { activeGp = gamepads[i]; break; } } if (activeGp) setGamepadStatus(`🟢 TERHUBUNG: ${activeGp.id.substring(0, 15)}...`); }; const saveTrajectory = async () => { isSavingRef.current = true; try { stopRobot(); const deltaL = Math.abs(currentTicksRef.current.l - lastRecordedTicksRef.current.l); const deltaR = Math.abs(currentTicksRef.current.r - lastRecordedTicksRef.current.r); if ((deltaL > 0 || deltaR > 0) && activeMotionRef.current !== 'STOP') { trajectoryRef.current.push({ x: lastCommandRef.current.x, y: lastCommandRef.current.y, target_ticks_l: deltaL, target_ticks_r: deltaR, motion: activeMotionRef.current, }); } const trajectoryToSave = [...trajectoryRef.current]; if (trajectoryToSave.length === 0) return; const res = await fetch('/api/trajectory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(trajectoryToSave), }); if (!res.ok) throw new Error(`HTTP Error ${res.status}`); } catch (error) { alert('Gagal menyimpan rute.'); } finally { isSavingRef.current = false; } }; const handleTouchAction = (action: 'RECORD' | 'SAVE' | 'PLAY_FWD' | 'PLAY_REV') => { if (action === 'PLAY_FWD') { if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } else if (!isSavingRef.current && !isRecordingRef.current) executePlayback(false); } else if (action === 'PLAY_REV') { if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } else if (!isSavingRef.current && !isRecordingRef.current) executePlayback(true); } else if (action === 'RECORD') { if (!isRecordingRef.current && !isPlayingRef.current && !isSavingRef.current) { isRecordingRef.current = true; setIsRecordingUI(true); trajectoryRef.current = []; lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; activeMotionRef.current = 'STOP'; lastCommandRef.current = { x: 0, y: 0 }; stopRobot(); } } else if (action === 'SAVE') { if (isRecordingRef.current) { isRecordingRef.current = false; setIsRecordingUI(false); saveTrajectory(); } } }; const enterFullscreen = () => { const elem = document.documentElement; if (elem.requestFullscreen) { elem.requestFullscreen(); } }; const exitFullscreen = () => { if (document.exitFullscreen) { document.exitFullscreen(); } }; useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); const ws = new WebSocket(`ws://${PI_IP}:8765`); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.ticks_l !== undefined && data.ticks_r !== undefined) { const newTicks = { l: Number(data.ticks_l), r: Number(data.ticks_r) }; setTicks(newTicks); currentTicksRef.current = newTicks; } } catch (error) {} }; wsRef.current = ws; // PERBAIKAN LOGIKA KEYBOARD (Q dan E ditambahkan & CapsLock kebal) const handleKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); if (key === 'arrowup' || key === 'w') keysRef.current.up = true; if (key === 'arrowdown' || key === 's') keysRef.current.down = true; if (key === 'arrowleft' || key === 'a') keysRef.current.left = true; if (key === 'arrowright' || key === 'd') keysRef.current.right = true; if (key === 'q') keysRef.current.q = true; if (key === 'e') keysRef.current.e = true; }; const handleKeyUp = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); if (key === 'arrowup' || key === 'w') keysRef.current.up = false; if (key === 'arrowdown' || key === 's') keysRef.current.down = false; if (key === 'arrowleft' || key === 'a') keysRef.current.left = false; if (key === 'arrowright' || key === 'd') keysRef.current.right = false; // Saat tombol dilepas, kembalikan posisi pan (menoleh) ke 0 secara paksa if (key === 'q') { keysRef.current.q = false; touchStateRef.current.pan = 0; } if (key === 'e') { keysRef.current.e = false; touchStateRef.current.pan = 0; } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); const interval = setInterval(() => { let currentX = 0; let currentY = 0; let currentPan = 0; let btnA = false; let btnB = false; let btnY = false; let btnX = false; if (controlMode === 'gamepad') { if (typeof navigator !== 'undefined' && navigator.getGamepads) { const gamepads = navigator.getGamepads(); let activeGp: Gamepad | null = null; for (let i = 0; i < gamepads.length; i++) { if (gamepads[i]) { activeGp = gamepads[i]; break; } } if (activeGp) { const rawX = activeGp.axes[0] || 0; const rawPan = activeGp.axes[2] || 0; const maju = activeGp.buttons[7]?.value || 0; const mundur = activeGp.buttons[6]?.value || 0; currentX = Math.abs(rawX) > 0.15 ? Number(rawX.toFixed(2)) : 0; currentY = Number((mundur - maju).toFixed(2)); currentPan = Math.abs(rawPan) > 0.15 ? Number(rawPan.toFixed(2)) : 0; btnA = activeGp.buttons[0]?.pressed || false; btnB = activeGp.buttons[1]?.pressed || false; btnY = activeGp.buttons[3]?.pressed || false; btnX = activeGp.buttons[2]?.pressed || false; } } } else if (controlMode === 'keyboard') { currentY = (keysRef.current.down ? 1 : 0) - (keysRef.current.up ? 1 : 0); currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0); currentPan = (keysRef.current.e ? 1 : 0) - (keysRef.current.q ? 1 : 0); } else if (controlMode === 'touch' || isFullscreen) { currentX = touchStateRef.current.turn; currentY = touchStateRef.current.throttle; currentPan = touchStateRef.current.pan; } // GLOBAL KEYBOARD OVERRIDE: Berlaku dimanapun asalkan tombol keyboard ditekan if (keysRef.current.up || keysRef.current.down || keysRef.current.left || keysRef.current.right) { currentY = (keysRef.current.down ? 1 : 0) - (keysRef.current.up ? 1 : 0); currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0); } if (keysRef.current.q || keysRef.current.e) { currentPan = (keysRef.current.e ? 1 : 0) - (keysRef.current.q ? 1 : 0); touchStateRef.current.pan = currentPan; // Sinkronkan ke UI Sentuh } const currentMotion = getMotionType(currentX, currentY); if (btnA && !prevBtnsRef.current.A && !isRecordingRef.current) { if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } else if (!isSavingRef.current) executePlayback(false); } if (btnX && !prevBtnsRef.current.X && !isRecordingRef.current) { if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } else if (!isSavingRef.current) executePlayback(true); } if (btnB && !prevBtnsRef.current.B && !isRecordingRef.current && !isPlayingRef.current && !isSavingRef.current) { isRecordingRef.current = true; setIsRecordingUI(true); trajectoryRef.current = []; lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; activeMotionRef.current = currentMotion; lastCommandRef.current = { x: currentX, y: currentY }; stopRobot(); } if (btnY && !prevBtnsRef.current.Y && isRecordingRef.current) { isRecordingRef.current = false; setIsRecordingUI(false); saveTrajectory(); } prevBtnsRef.current = { A: btnA, B: btnB, Y: btnY, X: btnX }; if (isPlayingRef.current || isSavingRef.current) return; if (isRecordingRef.current) { if (currentMotion !== activeMotionRef.current) { const deltaL = Math.abs(currentTicksRef.current.l - lastRecordedTicksRef.current.l); const deltaR = Math.abs(currentTicksRef.current.r - lastRecordedTicksRef.current.r); if ((deltaL > 0 || deltaR > 0) && activeMotionRef.current !== 'STOP') { trajectoryRef.current.push({ x: lastCommandRef.current.x, y: lastCommandRef.current.y, target_ticks_l: deltaL, target_ticks_r: deltaR, motion: activeMotionRef.current, }); } lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; activeMotionRef.current = currentMotion; } if (currentMotion !== 'STOP') { lastCommandRef.current = { x: currentX, y: currentY }; } } setSteering({ x: currentX, y: currentY }); sendCommand(currentX, currentY, currentPan); }, RECORD_INTERVAL_MS); return () => { clearInterval(interval); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); document.removeEventListener('fullscreenchange', handleFullscreenChange); if (wsRef.current) wsRef.current.close(); }; }, [controlMode, PI_IP, isFullscreen]); const maxRadius = 36; const dotX = steering.x * maxRadius; const dotY = steering.y * maxRadius; return (
e.preventDefault()} className="min-h-screen bg-slate-950 text-slate-100 p-3 md:p-8 font-sans pb-12 select-none" style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }} >
{/* HEADER */}

ROBOT VISION

{isRecordingUI && ( MEREKAM... )} {isPlayingUI && ( MEMUTAR ULANG... )}
{/* (KAMERA & TOUCH CONTROLLER INLINE) */}

Live Feed

{/* TOMBOL FULL-SCREEN */}
Robot view { e.currentTarget.style.display = 'none'; }} />
{/* TOUCH UI BARU SESUAI DESAIN */} {controlMode === 'touch' && (
{/* SISI KIRI: Aksi dan Slider Kamera */}
{/* 4 Tombol Aksi Berjejer */}
{/* Slider Kamera di Bawah Tombol Aksi */}
Q { touchStateRef.current.pan = Number(e.currentTarget.value); }} onPointerUp={(e) => { e.currentTarget.value = "0"; touchStateRef.current.pan = 0; }} onPointerLeave={(e) => { e.currentTarget.value = "0"; touchStateRef.current.pan = 0; }} className="w-full accent-red-500" /> E
{/* SISI KANAN: D-Pad Pergerakan Salib */}
{/* MAJU */} {/* KIRI DAN KANAN */}
{/* MUNDUR */}
)}
{/* KOLOM KANAN (STATUS) */}

Status Kontrol {controlMode === 'gamepad' && ( )}

{controlMode === 'touch' ? '📱 AKTIF: DUAL-PAD SENTUH' : controlMode === 'keyboard' ? '⌨️ AKTIF: W-A-S-D & Q-E' : gamepadStatus}
Visualizer Posisi
X: {steering.x}Y: {steering.y}
⚙️ L: {ticks.l}⚙️ R: {ticks.r}
{/* Tombol aksi cadangan untuk UI Desktop */}
Panel Cadangan Aksi
{/* OVERLAY LAYAR PENUH (FULL SCREEN) LAYOUT BARU */} {isFullscreen && (
Robot Fullscreen { e.currentTarget.style.display = 'none'; }} /> {/* AREA KONTROL OVERLAY */}
{/* SISI KIRI: Aksi dan Slider Kamera */}
{/* 4 Tombol Aksi Berjejer */}
{/* Slider Kamera di Bawah Tombol Aksi */}
Q { touchStateRef.current.pan = Number(e.currentTarget.value); }} onPointerUp={(e) => { e.currentTarget.value = "0"; touchStateRef.current.pan = 0; }} onPointerLeave={(e) => { e.currentTarget.value = "0"; touchStateRef.current.pan = 0; }} className="w-full accent-red-500" /> E
{/* SISI KANAN: D-Pad Pergerakan Salib */}
{/* MAJU */} {/* KIRI DAN KANAN */}
{/* MUNDUR */}
)}
); }