'use client'; import { useState, useEffect, useRef } from 'react'; import { Pixelify_Sans } from 'next/font/google'; const pixelify = Pixelify_Sans({ subsets: ['latin'], weight: ['400', '500', '600', '700'], }); // KOMPONEN LOGO PI4WD ROVER const PiRoverLogo = ({ scale = 1 }: { scale?: number }) => { return (
PI 4WD ROVER
); }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const RECORD_INTERVAL_MS = 50; const DIRECTION_BRAKE_MS = 600; const MAX_STEP_TIMEOUT_MS = 15000; const LATENCY_COMPENSATION_TICKS = 3; 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; }; export default function RobotDashboard() { const PI_IP = '192.168.11.110'; //GANTI IP JIKA ADA SETTING YANG BARU const streamUrl = `http://${PI_IP}:8080/stream`; const [isLoading, setIsLoading] = useState(true); const [controlMode, setControlMode] = useState<'pc-mobile' | 'gamepad'>('pc-mobile'); const [isFullscreen, setIsFullscreen] = useState(false); const [gpVisuals, setGpVisuals] = useState({ lt: 0, rt: 0, steer: 0, btnA: false, btnB: false, btnX: false, btnY: false }); const containerRef = useRef(null); 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 sendCommand = (x: number, y: number) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ x, y })); } }; const stopRobot = () => { sendCommand(0, 0); }; const getMotionType = (x: number, y: number): MotionType => { const DEADZONE = 0.10; if (Math.abs(x) <= DEADZONE && Math.abs(y) <= DEADZONE) return 'STOP'; if (Math.abs(y) > DEADZONE) 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; 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 rute kosong!'); 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') { if ((lastActiveMotion === 'FORWARD' && currentMotion === 'REVERSE') || (lastActiveMotion === 'REVERSE' && currentMotion === 'FORWARD')) { stopRobot(); await sleep(DIRECTION_BRAKE_MS); } } if (currentMotion !== 'STOP') lastActiveMotion = currentMotion; 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 || (Date.now() - stepStartTime > MAX_STEP_TIMEOUT_MS)) break; await sleep(10); } } } catch (error) { console.error('Playback Error:', error); } finally { stopRobot(); isPlayingRef.current = false; } }; const handleAction = async (action: 'RECORD' | 'SAVE' | 'PLAY_FWD' | 'PLAY_REV') => { if (action === 'PLAY_FWD' || action === 'PLAY_REV') { if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } else if (!isSavingRef.current && !isRecordingRef.current) { executePlayback(action === 'PLAY_REV'); } } else if (action === 'RECORD') { if (!isRecordingRef.current && !isPlayingRef.current && !isSavingRef.current) { isRecordingRef.current = 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; isSavingRef.current = true; if (trajectoryRef.current.length > 0) { try { const res = await fetch('/api/trajectory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(trajectoryRef.current) }); if (res.ok) alert('Rute berhasil disimpan!'); else alert('Gagal menyimpan rute.'); } catch (error) { console.error('Save error:', error); alert('Terjadi kesalahan sistem saat menyimpan.'); } } else { alert('Tidak ada data rute untuk disimpan (robot belum bergerak).'); } isSavingRef.current = false; } } }; useEffect(() => { const loadingTimer = setTimeout(() => setIsLoading(false), 2000); 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) { currentTicksRef.current = { l: Number(data.ticks_l), r: Number(data.ticks_r) }; } } catch (error) {} }; wsRef.current = ws; const handleKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); if (key === 'w' || key === 'arrowup') keysRef.current.up = true; if (key === 's' || key === 'arrowdown') keysRef.current.down = true; if (key === 'a' || key === 'arrowleft') keysRef.current.left = true; if (key === 'd' || key === 'arrowright') keysRef.current.right = true; if (e.repeat) return; if (key === 'v') handleAction('RECORD'); if (key === 'b') handleAction('SAVE'); if (key === 'n') handleAction('PLAY_FWD'); if (key === 'm') handleAction('PLAY_REV'); }; const handleKeyUp = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); if (key === 'w' || key === 'arrowup') keysRef.current.up = false; if (key === 's' || key === 'arrowdown') keysRef.current.down = false; if (key === 'a' || key === 'arrowleft') keysRef.current.left = false; if (key === 'd' || key === 'arrowright') keysRef.current.right = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); const interval = setInterval(() => { let currentX = 0; let currentY = 0; let gpA = false, gpB = false, gpY = false, gpX = false; let gpLT = 0, gpRT = 0, gpSteer = 0; if (controlMode === 'gamepad' && 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) { gpSteer = activeGp.axes[0] || 0; gpRT = activeGp.buttons[7]?.value || 0; gpLT = activeGp.buttons[6]?.value || 0; currentX = Math.abs(gpSteer) > 0.15 ? Number(gpSteer.toFixed(2)) : 0; currentY = Number((gpRT - gpLT).toFixed(2)); gpA = activeGp.buttons[0]?.pressed || false; gpB = activeGp.buttons[1]?.pressed || false; gpX = activeGp.buttons[2]?.pressed || false; gpY = activeGp.buttons[3]?.pressed || false; setGpVisuals({ lt: gpLT, rt: gpRT, steer: gpSteer, btnA: gpA, btnB: gpB, btnX: gpX, btnY: gpY }); } } else if (controlMode === 'pc-mobile') { currentY = (keysRef.current.up ? 1 : 0) - (keysRef.current.down ? 1 : 0); currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0); } const currentMotion = getMotionType(currentX, currentY); if (gpB && !prevBtnsRef.current.B) handleAction('RECORD'); if (gpY && !prevBtnsRef.current.Y) handleAction('SAVE'); if (gpA && !prevBtnsRef.current.A) handleAction('PLAY_FWD'); if (gpX && !prevBtnsRef.current.X) handleAction('PLAY_REV'); prevBtnsRef.current = { A: gpA, B: gpB, Y: gpY, X: gpX }; 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 }; } sendCommand(currentX, currentY); }, RECORD_INTERVAL_MS); return () => { clearTimeout(loadingTimer); clearInterval(interval); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); document.removeEventListener('fullscreenchange', handleFullscreenChange); if (wsRef.current) wsRef.current.close(); }; }, [controlMode, PI_IP]); const toggleFullscreen = () => { if (!document.fullscreenElement) { if (containerRef.current) { containerRef.current.requestFullscreen().catch((err) => console.log(err)); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } } }; return (
e.preventDefault()} className={`min-h-screen bg-emerald-50 flex flex-col select-none touch-none overflow-x-hidden ${pixelify.className}`} style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }} > {/* LOADING SCREEN */}
Menghubungkan ke Robot...
{/* HEADER LOGO */}
{/* MAIN CAMERA FEED & OVERLAY LAYOUT - DITENGAHKAN DAN DIPERBESAR */}
{/* Stream Kamera */} Robot Feed { e.currentTarget.style.display = 'none'; }} /> {/* Indikator Status Perekaman */} {(isRecordingRef.current || isPlayingRef.current) && (
{isRecordingRef.current && MEREKAM} {isPlayingRef.current && MEMUTAR RUTE}
)} {/* OVERLAY: TOP BAR */}
{/* OVERLAY: FULLSCREEN TOGGLE */} {/* OVERLAY: BOTTOM LEFT (MOVEMENT CONTROLS) */}
{controlMode === 'pc-mobile' ? (
) : (
0.1 ? 'bg-white text-slate-900 border-slate-900' : 'bg-slate-900/50 text-white border-white/50 backdrop-blur-sm'}`}>LT
0.1 ? 'bg-white text-slate-900 border-slate-900' : 'bg-slate-900/50 text-white border-white/50 backdrop-blur-sm'}`}>RT
L
R
)}
{/* OVERLAY: BOTTOM RIGHT (ACTION CONTROLS) */}
{[ { action: 'RECORD', pc: 'V', gp: 'B', color: 'bg-[#f87171] shadow-[4px_4px_0_#b91c1c]', activeColor: 'active:bg-red-500', gpState: gpVisuals.btnB }, { action: 'SAVE', pc: 'B', gp: 'Y', color: 'bg-[#fbbf24] shadow-[4px_4px_0_#d97706]', activeColor: 'active:bg-amber-500', gpState: gpVisuals.btnY }, { action: 'PLAY_FWD', pc: 'N', gp: 'A', color: 'bg-[#34d399] shadow-[4px_4px_0_#047857]', activeColor: 'active:bg-emerald-500', gpState: gpVisuals.btnA }, { action: 'PLAY_REV', pc: 'M', gp: 'X', color: 'bg-[#60a5fa] shadow-[4px_4px_0_#1d4ed8]', activeColor: 'active:bg-blue-500', gpState: gpVisuals.btnX } ].map((btn) => (
{btn.action === 'PLAY_FWD' ? 'PLAY' : btn.action === 'PLAY_REV' ? 'R-PLAY' : btn.action}
))}
{/* FOOTER TEXT */}
); }