'use client'; import { useState, useEffect, useRef } from 'react'; // ============================================================ // HELPER // ============================================================ 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); // State Fullscreen 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 }); const touchStateRef = useRef({ turn: 0, throttle: 0 }); const sendCommand = (x: number, y: number) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ x, y })); } }; const stopRobot = () => { setSteering({ x: 0, y: 0 }); sendCommand(0, 0); }; 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); 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(); } } }; // Fungsi Full-Screen const enterFullscreen = () => { const elem = document.documentElement; if (elem.requestFullscreen) { elem.requestFullscreen(); } }; const exitFullscreen = () => { if (document.exitFullscreen) { document.exitFullscreen(); } }; useEffect(() => { // Sinkronisasi status fullscreen dari sistem 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; const handleKeyDown = (e: KeyboardEvent) => { if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = true; if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = true; if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = true; if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = true; }; const handleKeyUp = (e: KeyboardEvent) => { if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = false; if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = false; if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = false; if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); const interval = setInterval(() => { let currentX = 0; let currentY = 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 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)); 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); } else if (controlMode === 'touch' || isFullscreen) { // Kontrol layar sentuh aktif baik di mode touch biasa maupun fullscreen currentX = touchStateRef.current.turn; currentY = touchStateRef.current.throttle; } 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); }, 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... )}
{/* KOLOM KIRI (KAMERA & TOUCH CONTROLLER) */}

Live Feed

{/* TOMBOL PEMICU FULL-SCREEN */}
{/* eslint-disable-next-line @next/next/no-img-element */} Robot view { e.currentTarget.style.display = 'none'; }} />
{/* DUAL-PAD SPLIT CONTROLLER UI BAWAAN */} {controlMode === 'touch' && (
{/* SISI KIRI: BELOK */}
Steering
{/* SISI KANAN: THROTTLE */}
Throttle
)}
{/* KOLOM KANAN (STATUS & AKSI RUTE) */}

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

{controlMode === 'touch' ? '📱 AKTIF: DUAL-PAD SENTUH' : controlMode === 'keyboard' ? '⌨️ AKTIF: W-A-S-D' : gamepadStatus}
Visualizer Posisi
X: {steering.x}Y: {steering.y}
⚙️ L: {ticks.l}⚙️ R: {ticks.r}
{/* TOMBOL AKSI RUTE */}
Memori Rute
{/* OVERLAY LAYAR PENUH (FULL SCREEN) */} {isFullscreen && (
{/* Live Feed Video Background */} {/* eslint-disable-next-line @next/next/no-img-element */} Robot Fullscreen { e.currentTarget.style.display = 'none'; }} /> {/* Tombol Keluar (Pojok Kanan Atas) */} {/* AREA KONTROL OVERLAY */}
{/* KIRI: Tombol Kiri & Kanan (Steering) */}
{/* TENGAH: 4 Tombol Aksi (D-Pad Style) */}
{/* Tombol Atas (Rekam) */} {/* Tombol Bawah (Simpan) */} {/* Tombol Kiri (Play Rev) */} {/* Tombol Kanan (Play Fwd) */}
{/* KANAN: Tombol Atas & Bawah (Throttle) */}
)}
); }