Compare commits
27
Commits
5d3f55dc84
...
main
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const dataFilePath = path.join(process.cwd(), 'trajectory.json');
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dataFilePath)) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
const fileData = fs.readFileSync(dataFilePath, 'utf8');
|
||||||
|
return NextResponse.json(JSON.parse(fileData));
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Gagal membaca data' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
fs.writeFileSync(dataFilePath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Gagal menyimpan data' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-26
@@ -1,26 +1,3 @@
|
|||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
:root {
|
@tailwind utilities;
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
+8
-8
@@ -1,20 +1,20 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter, Roboto_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const robotoMono = Roboto_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-roboto-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Robot UI",
|
||||||
description: "Generated by create next app",
|
description: "Robot Vision Control Dashboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,7 +25,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${inter.variable} ${robotoMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+375
-128
@@ -1,191 +1,438 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
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 (
|
||||||
|
<div className="flex flex-col items-center select-none" style={{ transform: `scale(${scale})` }}>
|
||||||
|
<div className="flex items-center font-extrabold text-3xl md:text-5xl tracking-widest">
|
||||||
|
<span className="text-red-600 relative -top-0.4">PI</span>
|
||||||
|
<span className="text-slate-900">4WD ROVER</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
export default function RobotDashboard() {
|
||||||
const PI_IP = '172.17.19.107';
|
const PI_IP = '192.168.11.110'; //GANTI IP JIKA ADA SETTING YANG BARU
|
||||||
const streamUrl = `http://${PI_IP}:8080/stream`;
|
const streamUrl = `http://${PI_IP}:8080/stream`;
|
||||||
|
|
||||||
const [controlMode, setControlMode] = useState('gamepad');
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [gamepadStatus, setGamepadStatus] = useState("Menunggu Gamepad...");
|
const [controlMode, setControlMode] = useState<'pc-mobile' | 'gamepad'>('pc-mobile');
|
||||||
const [steering, setSteering] = useState({ x: 0, y: 0 });
|
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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const currentTicksRef = useRef({ l: 0, r: 0 });
|
||||||
|
const lastRecordedTicksRef = useRef({ l: 0, r: 0 });
|
||||||
|
const activeMotionRef = useRef<MotionType>('STOP');
|
||||||
|
const lastCommandRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const isRecordingRef = useRef(false);
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const isSavingRef = useRef(false);
|
||||||
|
const trajectoryRef = useRef<TrajectoryStep[]>([]);
|
||||||
|
const prevBtnsRef = useRef({ A: false, B: false, Y: false, X: false });
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<any>(null);
|
|
||||||
const keysRef = useRef({ up: false, down: false, left: false, right: false });
|
const keysRef = useRef({ up: false, down: false, left: false, right: false });
|
||||||
|
|
||||||
const forceScanGamepad = () => {
|
const sendCommand = (x: number, y: number) => {
|
||||||
if (typeof navigator !== "undefined" && navigator.getGamepads) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
const gamepads = navigator.getGamepads();
|
wsRef.current.send(JSON.stringify({ x, y }));
|
||||||
let activeGp = null;
|
}
|
||||||
for (let i = 0; i < gamepads.length; i++) {
|
};
|
||||||
if (gamepads[i] !== null) {
|
|
||||||
activeGp = gamepads[i];
|
const stopRobot = () => { sendCommand(0, 0); };
|
||||||
break;
|
|
||||||
|
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 (activeGp) setGamepadStatus(`🟢 TERHUBUNG: ${activeGp.id.substring(0, 15)}...`);
|
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(() => {
|
useEffect(() => {
|
||||||
|
const loadingTimer = setTimeout(() => setIsLoading(false), 2000);
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://${PI_IP}:8765`);
|
const ws = new WebSocket(`ws://${PI_IP}:8765`);
|
||||||
ws.onopen = () => console.log("WebSocket Terhubung ke Pi!");
|
ws.onmessage = (event) => {
|
||||||
ws.onclose = () => console.log("WebSocket Terputus.");
|
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;
|
wsRef.current = ws;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = true;
|
const key = e.key.toLowerCase();
|
||||||
if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = true;
|
if (key === 'w' || key === 'arrowup') keysRef.current.up = true;
|
||||||
if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = true;
|
if (key === 's' || key === 'arrowdown') keysRef.current.down = true;
|
||||||
if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = 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 handleKeyUp = (e: KeyboardEvent) => {
|
||||||
if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = false;
|
const key = e.key.toLowerCase();
|
||||||
if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = false;
|
if (key === 'w' || key === 'arrowup') keysRef.current.up = false;
|
||||||
if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = false;
|
if (key === 's' || key === 'arrowdown') keysRef.current.down = false;
|
||||||
if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = 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('keydown', handleKeyDown);
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const handleFullscreenChange = () => {
|
||||||
let currentX = 0;
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
let currentY = 0;
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
|
||||||
if (controlMode === 'gamepad') {
|
const interval = setInterval(() => {
|
||||||
if (typeof navigator !== "undefined" && navigator.getGamepads) {
|
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();
|
const gamepads = navigator.getGamepads();
|
||||||
let activeGp = null;
|
let activeGp: Gamepad | null = null;
|
||||||
for (let i = 0; i < gamepads.length; i++) {
|
for (let i = 0; i < gamepads.length; i++) { if (gamepads[i]) { activeGp = gamepads[i]; break; } }
|
||||||
if (gamepads[i] !== null) {
|
|
||||||
activeGp = gamepads[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeGp) {
|
if (activeGp) {
|
||||||
const rawX = activeGp.axes[0];
|
gpSteer = activeGp.axes[0] || 0;
|
||||||
|
gpRT = activeGp.buttons[7]?.value || 0;
|
||||||
|
gpLT = activeGp.buttons[6]?.value || 0;
|
||||||
|
|
||||||
// SESUAI PERMINTAAN: RT(7) Maju, LT(6) Mundur
|
currentX = Math.abs(gpSteer) > 0.15 ? Number(gpSteer.toFixed(2)) : 0;
|
||||||
const maju = activeGp.buttons[7].value;
|
currentY = Number((gpRT - gpLT).toFixed(2));
|
||||||
const mundur = activeGp.buttons[6].value;
|
|
||||||
|
|
||||||
// PERBAIKAN 1: Karena Pi minta nilai minus untuk maju, pengurangannya dibalik (mundur - maju)
|
gpA = activeGp.buttons[0]?.pressed || false;
|
||||||
const throttle = mundur - maju;
|
gpB = activeGp.buttons[1]?.pressed || false;
|
||||||
|
gpX = activeGp.buttons[2]?.pressed || false;
|
||||||
|
gpY = activeGp.buttons[3]?.pressed || false;
|
||||||
|
|
||||||
currentX = Math.abs(rawX) > 0.15 ? Number(rawX.toFixed(2)) : 0;
|
setGpVisuals({ lt: gpLT, rt: gpRT, steer: gpSteer, btnA: gpA, btnB: gpB, btnX: gpX, btnY: gpY });
|
||||||
currentY = Number(throttle.toFixed(2));
|
|
||||||
}
|
}
|
||||||
}
|
} else if (controlMode === 'pc-mobile') {
|
||||||
} else if (controlMode === 'keyboard') {
|
currentY = (keysRef.current.up ? 1 : 0) - (keysRef.current.down ? 1 : 0);
|
||||||
// PERBAIKAN 2: Keyboard disamakan. W/Up (maju) menghasilkan minus (-1), S/Down menghasilkan plus (+1)
|
|
||||||
currentY = (keysRef.current.down ? 1 : 0) - (keysRef.current.up ? 1 : 0);
|
|
||||||
currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0);
|
currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSteering({ x: currentX, y: currentY });
|
const currentMotion = getMotionType(currentX, currentY);
|
||||||
|
|
||||||
if (wsRef.current && wsRef.current.readyState === 1) {
|
if (gpB && !prevBtnsRef.current.B) handleAction('RECORD');
|
||||||
wsRef.current.send(JSON.stringify({ x: currentX, y: currentY }));
|
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 });
|
||||||
}
|
}
|
||||||
}, 100);
|
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 () => {
|
return () => {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
if (wsRef.current) wsRef.current.close();
|
if (wsRef.current) wsRef.current.close();
|
||||||
};
|
};
|
||||||
}, [controlMode, PI_IP]);
|
}, [controlMode, PI_IP]);
|
||||||
|
|
||||||
// --- PERBAIKAN 3: VISUALIZER ---
|
const toggleFullscreen = () => {
|
||||||
const maxRadius = 36;
|
if (!document.fullscreenElement) {
|
||||||
const dotX = steering.x * maxRadius;
|
if (containerRef.current) {
|
||||||
|
containerRef.current.requestFullscreen().catch((err) => console.log(err));
|
||||||
// Karena 'maju' nilainya sudah minus (-), dan di CSS nilai minus (-) membuat titik NAIK KE ATAS,
|
}
|
||||||
// maka kita HAPUS perkalian (* -1) yang sebelumnya. Sekarang titiknya akan maju sempurna!
|
} else {
|
||||||
const dotY = steering.y * maxRadius;
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-900 text-white p-8 font-sans">
|
<main
|
||||||
<div className="max-w-5xl mx-auto">
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
<header className="mb-8 border-b border-gray-700 pb-4 flex flex-col md:flex-row justify-between items-center gap-4">
|
className={`min-h-screen bg-emerald-50 flex flex-col select-none touch-none overflow-x-hidden ${pixelify.className}`}
|
||||||
<h1 className="text-3xl font-bold text-emerald-400">Robot Vision Control</h1>
|
style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||||
|
|
||||||
<div className="flex gap-2 bg-gray-800 p-1 rounded-lg border border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setControlMode('gamepad')}
|
|
||||||
className={`py-2 px-4 rounded font-semibold transition-colors ${controlMode === 'gamepad' ? 'bg-emerald-500 text-gray-900' : 'text-gray-400 hover:text-white'}`}
|
|
||||||
>
|
>
|
||||||
🎮 Gamepad
|
{/* LOADING SCREEN */}
|
||||||
</button>
|
<div className={`fixed inset-0 z-[200] bg-emerald-50 flex flex-col items-center justify-center transition-opacity duration-1000 ease-in-out ${isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||||
<button
|
<div className="scale-125 md:scale-150"><PiRoverLogo /></div>
|
||||||
onClick={() => setControlMode('keyboard')}
|
<div className="mt-16 flex items-center gap-3 text-slate-500 text-sm tracking-widest uppercase">
|
||||||
className={`py-2 px-4 rounded font-semibold transition-colors ${controlMode === 'keyboard' ? 'bg-emerald-500 text-gray-900' : 'text-gray-400 hover:text-white'}`}
|
<div className="w-3 h-3 rounded-sm bg-emerald-500 animate-pulse"></div>
|
||||||
>
|
Menghubungkan ke Robot...
|
||||||
⌨️ Keyboard
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HEADER LOGO */}
|
||||||
|
<header className="w-full flex justify-center pt-6 pb-2 md:pt-8 md:pb-4 bg-emerald-50 relative z-0">
|
||||||
|
<PiRoverLogo />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* MAIN CAMERA FEED & OVERLAY LAYOUT - DITENGAHKAN DAN DIPERBESAR */}
|
||||||
<div className="lg:col-span-2 bg-gray-800 rounded-xl p-4 border border-gray-700 shadow-xl">
|
<div className="w-full px-4 md:px-8 flex-1 flex flex-col items-center justify-center relative z-10">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 mb-4">
|
|
||||||
<span className="w-3 h-3 rounded-full bg-red-500 animate-pulse"></span>
|
|
||||||
Live Feed
|
|
||||||
</h2>
|
|
||||||
<div className="aspect-video bg-black rounded-lg overflow-hidden border border-gray-900 relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={streamUrl}
|
|
||||||
alt="Robot view"
|
|
||||||
// PERUBAHAN DI SINI: Posisi absolut ke kiri, hilangkan max-width, dan set object-position ke kiri
|
|
||||||
className="absolute top-0 left-0 h-full max-w-none object-cover"
|
|
||||||
style={{ width: '200%', objectPosition: 'left' }}
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
e.currentTarget.parentElement!.innerHTML = '<div class="text-red-400 text-center p-4">Kamera gagal dimuat.<br/>Pastikan Pi dan HP berada dalam jaringan Tailscale/Wi-Fi yang sama.</div>';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-800 rounded-xl p-4 border border-gray-700 shadow-xl flex flex-col">
|
|
||||||
<h2 className="text-lg font-semibold mb-4 border-b border-gray-700 pb-2 flex justify-between items-center">
|
|
||||||
<span>Status Kontrol</span>
|
|
||||||
{controlMode === 'gamepad' && (
|
|
||||||
<button onClick={forceScanGamepad} className="text-xs bg-gray-700 hover:bg-gray-600 py-1 px-2 rounded">
|
|
||||||
Pindai Ulang
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col justify-center gap-6">
|
|
||||||
<div className="bg-gray-900 p-3 rounded border border-gray-700 text-sm font-mono text-center">
|
|
||||||
{controlMode === 'keyboard' ? '⌨️ AKTIF: Gunakan W-A-S-D atau Panah' : gamepadStatus}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-900 p-4 rounded-lg flex flex-col items-center gap-4 border border-gray-700">
|
|
||||||
<div className="text-xs text-gray-400 text-center mb-2">Visualizer (X / Y)</div>
|
|
||||||
<div className="w-24 h-24 bg-gray-800 rounded-full border-2 border-gray-600 relative overflow-hidden flex items-center justify-center">
|
|
||||||
<div className="absolute w-full h-1px bg-gray-600"></div>
|
|
||||||
<div className="absolute h-full w-1px bg-gray-600"></div>
|
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 bg-emerald-400 rounded-full absolute shadow-[0_0_10px_rgba(52,211,153,0.5)] transition-all duration-75"
|
ref={containerRef}
|
||||||
style={{ transform: `translate(${dotX}px, ${dotY}px)` }}
|
className={`relative bg-[#0a0a8a] overflow-hidden shadow-2xl transition-all duration-300 ${
|
||||||
></div>
|
isFullscreen
|
||||||
</div>
|
? 'fixed inset-0 z-[150] bg-black border-0 rounded-none w-full h-full'
|
||||||
<div className="font-mono text-sm mt-2 flex flex-col items-center text-emerald-400">
|
: 'w-full max-w-7xl aspect-[4/3] md:aspect-video rounded-xl md:rounded-2xl border-4 border-slate-900'
|
||||||
<span>X: {steering.x}</span>
|
}`}
|
||||||
<span>Y: {steering.y}</span>
|
>
|
||||||
</div>
|
|
||||||
</div>
|
{/* Stream Kamera */}
|
||||||
|
<img src={streamUrl} alt="Robot Feed" className="absolute inset-0 w-full h-full object-cover" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||||
|
|
||||||
|
{/* Indikator Status Perekaman */}
|
||||||
|
{(isRecordingRef.current || isPlayingRef.current) && (
|
||||||
|
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-20 flex gap-2">
|
||||||
|
{isRecordingRef.current && <span className="bg-red-600 text-white px-4 py-1.5 rounded-sm text-sm tracking-wider animate-pulse shadow-lg border-2 border-red-800">MEREKAM</span>}
|
||||||
|
{isPlayingRef.current && <span className="bg-emerald-600 text-white px-4 py-1.5 rounded-sm text-sm tracking-wider animate-pulse shadow-lg border-2 border-emerald-800">MEMUTAR RUTE</span>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OVERLAY: TOP BAR */}
|
||||||
|
<div className="absolute top-4 left-0 right-0 flex justify-center z-20 pointer-events-none">
|
||||||
|
<div className="flex gap-2 pointer-events-auto shadow-lg bg-slate-900/70 p-1.5 border-2 border-slate-700/50 backdrop-blur-sm rounded-md">
|
||||||
|
<button
|
||||||
|
onClick={() => setControlMode('pc-mobile')}
|
||||||
|
className={`px-4 py-1.5 rounded-sm text-sm tracking-wider transition-all ${controlMode === 'pc-mobile' ? 'bg-[#b6e9c9] text-slate-900' : 'bg-transparent text-white hover:bg-white/20'}`}
|
||||||
|
>
|
||||||
|
PC/MOBILE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setControlMode('gamepad')}
|
||||||
|
className={`px-4 py-1.5 rounded-sm text-sm tracking-wider transition-all ${controlMode === 'gamepad' ? 'bg-[#b6e9c9] text-slate-900' : 'bg-transparent text-white hover:bg-white/20'}`}
|
||||||
|
>
|
||||||
|
GAMEPAD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OVERLAY: FULLSCREEN TOGGLE */}
|
||||||
|
<button onClick={toggleFullscreen} className="absolute top-5 right-5 z-20 flex items-center gap-2 text-white text-sm tracking-widest drop-shadow-md hover:text-emerald-300 pointer-events-auto bg-slate-900/50 px-3 py-1.5 rounded-sm border border-white/20">
|
||||||
|
{isFullscreen ? 'KELUAR' : 'FULLSCREEN'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* OVERLAY: BOTTOM LEFT (MOVEMENT CONTROLS) */}
|
||||||
|
<div className="absolute bottom-6 left-6 md:bottom-8 md:left-8 z-20 pointer-events-auto">
|
||||||
|
{controlMode === 'pc-mobile' ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); keysRef.current.up = true; }}
|
||||||
|
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); keysRef.current.up = false; }}
|
||||||
|
className="w-14 h-14 md:w-16 md:h-16 bg-[#a2dfb4] active:bg-[#8bc99d] text-slate-900 text-3xl rounded-sm shadow-[4px_4px_0_#75a884] active:shadow-none active:translate-x-1 active:translate-y-1 transition-all border-2 border-slate-900 flex items-center justify-center pb-1"
|
||||||
|
>
|
||||||
|
W
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); keysRef.current.left = true; }}
|
||||||
|
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); keysRef.current.left = false; }}
|
||||||
|
className="w-14 h-14 md:w-16 md:h-16 bg-[#a2dfb4] active:bg-[#8bc99d] text-slate-900 text-3xl rounded-sm shadow-[4px_4px_0_#75a884] active:shadow-none active:translate-x-1 active:translate-y-1 transition-all border-2 border-slate-900 flex items-center justify-center pb-1"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); keysRef.current.down = true; }}
|
||||||
|
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); keysRef.current.down = false; }}
|
||||||
|
className="w-14 h-14 md:w-16 md:h-16 bg-[#a2dfb4] active:bg-[#8bc99d] text-slate-900 text-3xl rounded-sm shadow-[4px_4px_0_#75a884] active:shadow-none active:translate-x-1 active:translate-y-1 transition-all border-2 border-slate-900 flex items-center justify-center pb-1"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); keysRef.current.right = true; }}
|
||||||
|
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); keysRef.current.right = false; }}
|
||||||
|
className="w-14 h-14 md:w-16 md:h-16 bg-[#a2dfb4] active:bg-[#8bc99d] text-slate-900 text-3xl rounded-sm shadow-[4px_4px_0_#75a884] active:shadow-none active:translate-x-1 active:translate-y-1 transition-all border-2 border-slate-900 flex items-center justify-center pb-1"
|
||||||
|
>
|
||||||
|
D
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 pointer-events-none">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className={`w-16 h-10 rounded-sm flex items-center justify-center text-xl border-2 transition-colors ${gpVisuals.lt > 0.1 ? 'bg-white text-slate-900 border-slate-900' : 'bg-slate-900/50 text-white border-white/50 backdrop-blur-sm'}`}>LT</div>
|
||||||
|
<div className={`w-16 h-10 rounded-sm flex items-center justify-center text-xl border-2 transition-colors ${gpVisuals.rt > 0.1 ? 'bg-white text-slate-900 border-slate-900' : 'bg-slate-900/50 text-white border-white/50 backdrop-blur-sm'}`}>RT</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-900/80 rounded-sm p-2 h-14 flex items-center justify-center relative shadow-lg border-2 border-white/20">
|
||||||
|
<div className="absolute left-3 text-white/50">L</div>
|
||||||
|
<div className="w-8 h-8 bg-red-500 rounded-sm transition-transform border-2 border-slate-900" style={{ transform: `translateX(${gpVisuals.steer * 40}px)` }}></div>
|
||||||
|
<div className="absolute right-3 text-white/50">R</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OVERLAY: BOTTOM RIGHT (ACTION CONTROLS) */}
|
||||||
|
<div className="absolute bottom-6 right-6 md:bottom-8 md:right-8 z-20 flex gap-2 md:gap-3 pointer-events-auto">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div key={btn.action} className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-[10px] md:text-xs text-white tracking-widest bg-slate-900/50 px-1.5 py-0.5 rounded-sm">
|
||||||
|
{btn.action === 'PLAY_FWD' ? 'PLAY' : btn.action === 'PLAY_REV' ? 'R-PLAY' : btn.action}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(btn.action as any)}
|
||||||
|
className={`w-12 h-12 md:w-14 md:h-14 ${btn.color} ${btn.activeColor} ${controlMode === 'gamepad' && btn.gpState ? 'translate-x-1 translate-y-1 shadow-none brightness-90' : ''} text-slate-900 text-2xl md:text-3xl rounded-sm active:shadow-none active:translate-x-1 active:translate-y-1 transition-all border-2 border-slate-900 flex items-center justify-center pb-1`}
|
||||||
|
>
|
||||||
|
{controlMode === 'pc-mobile' ? btn.pc : btn.gp}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* FOOTER TEXT FIX */}
|
||||||
|
<footer className="w-full py-6 md:py-8 flex flex-col items-center justify-center text-center px-4 relative z-0">
|
||||||
|
<h3 className="text-slate-600 tracking-widest text-xs md:text-sm uppercase mb-1">
|
||||||
|
LABORATORIUM INSTRUMENTASI ELEKTRONIKA & KECERDASAN BUATAN
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 tracking-widest text-xs md:text-sm uppercase">
|
||||||
|
PROGRAM STUDI TEKNIK ELEKTRO
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-600 tracking-widest text-xs md:text-sm uppercase">
|
||||||
|
FAKULTAS TEKNIK
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-600 tracking-widest text-xs md:text-sm uppercase font-semibold mt-1">
|
||||||
|
UNIVERSITAS KATOLIK WIDYA MANDALA SURABAYA
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'export',
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
Generated
+1525
-1394
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -9,18 +9,20 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.12",
|
"next": "^14.2.15",
|
||||||
"react": "19.2.4",
|
"react": "^18.3.1",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"autoprefixer": "^10.5.4",
|
||||||
"eslint-config-next": "16.2.12",
|
"eslint": "^8.57.1",
|
||||||
"tailwindcss": "^4",
|
"eslint-config-next": "^14.2.15",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": -0.6,
|
||||||
|
"target_ticks_l": 19,
|
||||||
|
"target_ticks_r": 23,
|
||||||
|
"motion": "REVERSE"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 0,
|
||||||
|
"target_ticks_l": 57,
|
||||||
|
"target_ticks_r": 58,
|
||||||
|
"motion": "TURN_RIGHT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": -1,
|
||||||
|
"y": 0,
|
||||||
|
"target_ticks_l": 6,
|
||||||
|
"target_ticks_r": 6,
|
||||||
|
"motion": "TURN_LEFT"
|
||||||
|
}
|
||||||
|
]
|
||||||
+14
-5
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -28,7 +34,10 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts",
|
||||||
|
"next.config.mjs"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user