feat: menyusun huruf kuis + mekanisme

This commit is contained in:
mphstar 2025-02-23 19:55:18 +07:00
parent 8c38a0a1b1
commit 2069915578
17 changed files with 964 additions and 47 deletions

75
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@mediapipe/tasks-vision": "^0.10.14", "@mediapipe/tasks-vision": "^0.10.14",
"@tensorflow/tfjs": "^4.20.0", "@tensorflow/tfjs": "^4.20.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"motion": "^12.4.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
@ -2815,6 +2816,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.7.tgz",
"integrity": "sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.4.5",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3317,6 +3345,47 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.4.7.tgz",
"integrity": "sha512-mhegHAbf1r80fr+ytC6OkjKvIUegRNXKLWNPrCN2+GnixlNSPwT03FtKqp9oDny1kNcLWZvwbmEr+JqVryFrcg==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.4.7",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.4.5",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.5.tgz",
"integrity": "sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.0.0"
}
},
"node_modules/motion-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -4360,6 +4429,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "dev": true
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -14,6 +14,7 @@
"@mediapipe/tasks-vision": "^0.10.14", "@mediapipe/tasks-vision": "^0.10.14",
"@tensorflow/tfjs": "^4.20.0", "@tensorflow/tfjs": "^4.20.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"motion": "^12.4.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",

BIN
public/assets/gif/betul.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

BIN
public/assets/gif/salah.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,17 @@
import { motion } from "framer-motion";
type ProgressBarProps = {
progress: number; // nilai antara 0 - 100
};
export default function ProgressBar({ progress }: ProgressBarProps) {
return (
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<motion.div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${progress}%` }}
transition={{ ease: "easeInOut" }}
/>
</div>
);
}

View File

@ -3,7 +3,7 @@ import React from 'react';
const MyLoading: React.FC = () => { const MyLoading: React.FC = () => {
return ( return (
<div className="flex flex-col items-center justify-center h-screen"> <div className="flex flex-col items-center justify-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-4 border-b-4 border-primary"></div> <div className="loader"></div>
<p className="mt-4 text-lg text-gray-700">Loading...</p> <p className="mt-4 text-lg text-gray-700">Loading...</p>
</div> </div>
); );

View File

@ -8,4 +8,24 @@
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@apply font-poppins; @apply font-poppins;
} }
.loader {
width: 24px;
height: 24px;
border: 3px solid black;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -37,6 +37,7 @@ const Home = () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
} }
setLoadCamera(true);
// setLoadCamera(true); // setLoadCamera(true);
await initializeHandDetection(); await initializeHandDetection();
@ -145,7 +146,7 @@ const Home = () => {
loadModel(); loadModel();
startWebcam(); startWebcam();
setLoadCamera(true);
return () => { return () => {
if (handLandmarker) { if (handLandmarker) {
@ -180,7 +181,7 @@ const Home = () => {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center flex-1"> <div className="flex flex-col items-center justify-center flex-1">
<div className="animate-spin rounded-full h-12 w-12 border-t-4 border-b-4 border-primary"></div> <div className="loader"></div>
<p className="mt-4 text-lg text-gray-700">Loading...</p> <p className="mt-4 text-lg text-gray-700">Loading...</p>
</div> </div>
)} )}

View File

@ -1,6 +1,7 @@
import LayoutPage from "@/components/templates/LayoutPage"; import LayoutPage from "@/components/templates/LayoutPage";
import useNavbarStore from "@/stores/NavbarStore"; import useNavbarStore from "@/stores/NavbarStore";
import { useEffect } from "react"; import { useEffect } from "react";
import { Link } from "react-router-dom";
const Kuis = () => { const Kuis = () => {
const store = useNavbarStore(); const store = useNavbarStore();
@ -11,31 +12,39 @@ const Kuis = () => {
return ( return (
<LayoutPage> <LayoutPage>
<div className="flex flex-col flex-1 py-4"> <div className="flex flex-col flex-1 py-4">
<h1 className="font-semibold text-3xl">Ayoo Kuiss</h1> <h1 className="font-semibold text-xl md:text-3xl">Ayoo Kuiss</h1>
<p>Be the first!</p> <p>Be the first!</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-24 md:gap-6 mt-24 md:mt-52 h-full mb-12"> <div className="grid grid-cols-1 md:grid-cols-2 gap-24 md:gap-6 mt-24 md:mt-52 h-full mb-12">
<div className="relative "> <Link to="/kuis/tebak-huruf">
<img <div className="relative ">
className="absolute w-24 -top-12 left-4 md:left-12" <img
src="/assets/images/tebak-huruf.png" className="absolute w-24 -top-12 left-4 md:left-12"
alt="Tebak Huruf" src="/assets/images/tebak-huruf.png"
/> alt="Tebak Huruf"
<div className="flex flex-col w-full bg-[#7FAFEF] hover:bg-[#6290cc] rounded-md px-4 md:px-12 py-6 pb-12 text-white pt-32 duration-300"> />
<h1 className="font-semibold text-4xl">Tebak Huruf</h1> <div className="flex flex-col w-full bg-[#7FAFEF] hover:bg-[#6290cc] rounded-md px-4 md:px-12 py-6 pb-12 text-white pt-32 duration-300">
<p>Tebak huruf dan coba simulasikan</p> <h1 className="font-semibold text-xl md:text-4xl">
Tebak Huruf
</h1>
<p>Tebak huruf dan coba simulasikan</p>
</div>
</div> </div>
</div> </Link>
<div className="relative "> <Link to="/kuis/menyusun-huruf">
<img <div className="relative ">
className="absolute w-42 -top-12 left-4 md:left-12" <img
src="/assets/images/menyusun-huruf.png" className="absolute w-42 -top-12 left-4 md:left-12"
alt="Menyusun Huruf" src="/assets/images/menyusun-huruf.png"
/> alt="Menyusun Huruf"
<div className="flex flex-col w-full bg-[#FF6884] hover:bg-[#d3546b] rounded-md px-4 md:px-12 py-6 pb-12 text-white pt-32 duration-300"> />
<h1 className="font-semibold text-4xl">Menyusun Huruf</h1> <div className="flex flex-col w-full bg-[#FF6884] hover:bg-[#d3546b] rounded-md px-4 md:px-12 py-6 pb-12 text-white pt-32 duration-300">
<p>Susun huruf jadi kata yang tepat</p> <h1 className="font-semibold text-xl md:text-4xl">
Menyusun Huruf
</h1>
<p>Susun huruf jadi kata yang tepat</p>
</div>
</div> </div>
</div> </Link>
</div> </div>
</div> </div>
</LayoutPage> </LayoutPage>

View File

@ -0,0 +1,66 @@
import LayoutPage from "@/components/templates/LayoutPage";
import useMenyusunHurufStore from "@/stores/MenyusunHurufStore";
import { Link } from "react-router-dom";
const MenyusunHuruf = () => {
const quizStore = useMenyusunHurufStore();
const shuffleArray = (array: any[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
return (
<LayoutPage>
<div className="flex flex-col flex-1 py-4 relative">
<ul className="flex gap-3 mt-4">
<li className="hover:text-primary cursor-default">Home</li>
<li>{">"}</li>
<Link to="/kuis" className="hover:text-primary">
Kuis
</Link>
<li>{">"}</li>
<li className="font-medium text-gray-500">Menyusun Huruf</li>
</ul>
<div className="flex-1 flex flex-col mt-12 md:mt-24 w-full md:w-[500px]">
<h1 className="text-5xl font-semibold">
Start Your <span className="text-primary">Quiz!</span>
</h1>
<div className="flex flex-col gap-3 w-full mt-24 md:mt-36">
<h1 className="font-semibold text-xl">Masukkan Nama</h1>
<div className="bg-[#F2F2F2] rounded-full pr-4 flex py-2 items-center">
<input
placeholder="Name.."
type="text"
className="bg-transparent outline-none p-2 px-8 flex-1 w-full"
/>
<Link to="/kuis/menyusun-huruf/app">
<button
onClick={() => {
quizStore.setListSoal(shuffleArray(quizStore.listSoal));
quizStore.setSession(true)
}}
className="bg-blue-500 hover:bg-blue-700 text-white px-3 py-2 rounded-full whitespace-nowrap"
>
Mulai Kuis
</button>
</Link>
</div>
<span className="text-primary">Lihat Ranking</span>
</div>
</div>
<img
className="absolute right-0 md:top-36 bottom-0 pointer-events-none"
src="/assets/images/overlay-bg.png"
alt="Overlay Background"
/>
</div>
</LayoutPage>
);
};
export default MenyusunHuruf;

View File

@ -0,0 +1,327 @@
import LayoutPage from "@/components/templates/LayoutPage";
import { useEffect, useRef, useState } from "react";
import * as tf from "@tensorflow/tfjs";
import { FilesetResolver, HandLandmarker } from "@mediapipe/tasks-vision";
import calcLandmarkList from "@/utils/CalculateLandmark";
import preProcessLandmark from "@/utils/PreProcessLandmark";
import { abjads } from "@/utils/ConvertResult";
import useNavbarStore from "@/stores/NavbarStore";
import ProgressBar from "@/components/molecules/ProgressBar";
import { MdOutlineQuiz } from "react-icons/md";
import useMenyusunHurufStore from "@/stores/MenyusunHurufStore";
import { useNavigate } from "react-router-dom";
// type PredictResult = {
// abjad: String;
// acc: String;
// };
const Quiz = () => {
const quizStore = useMenyusunHurufStore();
useEffect(() => {
if (!quizStore.session) {
window.location.href = "/kuis/menyusun-huruf";
}
}, []);
const videoRef = useRef<HTMLVideoElement>(null);
const [loadCamera, setLoadCamera] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// const [setResultPredict] = useState<PredictResult>({
// abjad: "",
// acc: "",
// });
const [showAnswer, setShowAnswer] = useState(false);
let model: tf.LayersModel;
let handLandmarker: HandLandmarker;
const [handPresence, setHandPresence] = useState(false);
const startWebcam = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
setLoadCamera(true);
// setLoadCamera(true);
await initializeHandDetection();
} catch (error) {
console.error("Error accessing webcam:", error);
}
};
const loadModel = async () => {
setLoadCamera(false);
try {
const lm = await tf.loadLayersModel("/model/model.json");
model = lm;
const emptyInput = tf.tensor2d([[0, 0]]);
model.predict(emptyInput) as tf.Tensor;
setLoadCamera(true);
} catch (error) {
// console.error("Error loading model:", error);
}
};
const initializeHandDetection = async () => {
try {
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
);
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
},
numHands: 2,
runningMode: "VIDEO",
});
detectHands();
} catch (error) {
console.error("Error initializing hand detection:", error);
}
};
const [progress, setProgress] = useState(0);
const [answer, setAnswer] = useState("");
let tempAnswer = "";
let noSoal = 0;
let startTimer: number = 0;
let answerTime: number = 0;
const navigate = useNavigate();
const [resultAnswer, setResultAnswer] = useState({
minutes: 0,
seconds: 0,
});
const makePrediction = async (finalResult: any) => {
if (startTimer === 0) {
startTimer = Date.now(); // Mulai waktu saat pertama kali prediksi
}
const input = tf.tensor2d([finalResult]);
// Melakukan prediksi
const prediction = model.predict(input) as tf.Tensor;
const result = prediction.dataSync();
const maxEntry = Object.entries(result).reduce((max, entry) => {
const [, value] = entry;
return value > max[1] ? entry : max;
});
// maxEntry sekarang berisi [key, value] dengan nilai terbesar
const [maxKey] = maxEntry;
// const percentageValue = (maxValue * 100).toFixed(2) + "%";
let currentResult = abjads[parseInt(maxKey)];
// Hapus tensor
input.dispose();
prediction.dispose();
if (tempAnswer.length == 0) {
const firstChar = quizStore.listSoal[noSoal].charAt(0);
console.log(noSoal);
if (currentResult === firstChar) {
tempAnswer += currentResult;
}
}
if (
currentResult === quizStore.listSoal[noSoal].charAt(tempAnswer.length)
) {
tempAnswer += currentResult;
}
setAnswer(tempAnswer);
if (tempAnswer === quizStore.listSoal[noSoal]) {
setShowAnswer(true);
const elapsedTime = (Date.now() - startTimer) / 1000;
const minutes = Math.floor(elapsedTime / 60);
const seconds = Math.floor(elapsedTime % 60);
setResultAnswer({
minutes,
seconds,
});
startTimer = 0;
tempAnswer = "";
setAnswer("");
setTimeout(() => {
setShowAnswer(false);
setResultAnswer({
minutes: 0,
seconds: 0,
});
}, 3000);
quizStore.setSoalIndex(noSoal + 1);
answerTime = answerTime += elapsedTime;
noSoal++;
if (noSoal === 10) {
quizStore.setTime(answerTime);
quizStore.setSession(false);
navigate("/kuis/menyusun-huruf/");
}
}
};
const detectHands = async () => {
if (showAnswer) {
return;
}
if (videoRef.current && videoRef.current.readyState >= 2) {
const detections = handLandmarker.detectForVideo(
videoRef.current,
performance.now()
);
setHandPresence(detections.handedness.length > 0);
// Assuming detections.landmarks is an array of landmark objects
if (detections.landmarks) {
if (detections.handednesses.length > 0) {
// console.log(detections);
if (detections.handednesses[0][0].displayName === "Right") {
const landm = detections.landmarks[0].map((landmark) => landmark);
const calt = calcLandmarkList(videoRef.current, landm);
const finalResult = preProcessLandmark(calt);
makePrediction(finalResult);
} else {
setHandPresence(false);
setProgress(0);
}
} else {
setProgress(0);
}
}
}
requestAnimationFrame(detectHands);
};
const store = useNavbarStore();
useEffect(() => {
store.setNavSelected("kuis");
loadModel();
startWebcam();
return () => {
if (handLandmarker) {
handLandmarker.close();
}
};
}, []);
return (
<LayoutPage>
<div
className={`fixed inset-0 w-screen h-screen bg-black/60 ${
showAnswer ? "opacity-100" : "opacity-0"
} z-[999] flex items-center justify-center pointer-events-none duration-300 ease-in-out`}
>
<div className="rounded-md px-3 py-2 text-white flex flex-col justify-center items-center gap-3">
<img
className="h-56"
src="/assets/gif/betul.gif"
alt="Jawaban Betul"
/>
<h1 className="text-2xl font-semibold text-center">
Kamu berhasil menyusun dalam {resultAnswer.minutes} Menit{" "}
{resultAnswer.seconds} Detik
</h1>
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<MdOutlineQuiz size={18} />
<h1 className="text-xl font-semibold">
Soal: {quizStore.soalIndex + 1} / 10
</h1>
</div>
<div className="flex flex-col flex-1 py-4">
{loadCamera ? (
<div className="rounded-md overflow-hidden relative">
{!showAnswer && (
<div className="top-6 left-6 absolute flex flex-col gap-2">
<div className="flex gap-2 items-center bg-white text-black rounded-md drop-shadow px-3 py-2">
<h1 className="text-2xl font-semibold text-center">
Susun huruf "{quizStore.listSoal[quizStore.soalIndex]}"
</h1>
</div>
{answer.length > 0 && (
<div className="flex gap-2 items-center bg-white text-black w-fit rounded-md drop-shadow px-3 py-2">
<h1 className="text-2xl font-semibold text-center">
{answer}
</h1>
</div>
)}
</div>
)}
{handPresence && !showAnswer && (
<div className="top-6 right-6 absolute flex gap-2 items-center bg-white text-black rounded-md drop-shadow px-3 py-2 w-fit">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="loader"></span>
<h1>Tahan Tangan..</h1>
</div>
<ProgressBar progress={progress} />
</div>
</div>
)}
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full z-20"
/>
<video
ref={videoRef}
className="w-full max-h-[80svh] object-cover"
autoPlay
playsInline
></video>
</div>
) : (
<div className="flex flex-col items-center justify-center flex-1">
<div className="loader"></div>
<p className="mt-4 text-lg text-gray-700">Loading...</p>
</div>
)}
</div>
</LayoutPage>
);
};
export default Quiz;

View File

@ -0,0 +1,266 @@
import LayoutPage from "@/components/templates/LayoutPage";
import { useEffect, useRef, useState } from "react";
import * as tf from "@tensorflow/tfjs";
import { FilesetResolver, HandLandmarker } from "@mediapipe/tasks-vision";
import calcLandmarkList from "@/utils/CalculateLandmark";
import preProcessLandmark from "@/utils/PreProcessLandmark";
import ConvertResult from "@/utils/ConvertResult";
import useNavbarStore from "@/stores/NavbarStore";
import ProgressBar from "@/components/molecules/ProgressBar";
import { MdOutlineQuiz } from "react-icons/md";
// type PredictResult = {
// abjad: String;
// acc: String;
// };
const Quiz = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const [loadCamera, setLoadCamera] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// const [setResultPredict] = useState<PredictResult>({
// abjad: "",
// acc: "",
// });
const [showAnswer, setShowAnswer] = useState(false);
let model: tf.LayersModel;
let handLandmarker: HandLandmarker;
const [handPresence, setHandPresence] = useState(false);
const startWebcam = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
setLoadCamera(true);
// setLoadCamera(true);
await initializeHandDetection();
} catch (error) {
console.error("Error accessing webcam:", error);
}
};
const loadModel = async () => {
setLoadCamera(false);
try {
const lm = await tf.loadLayersModel("/model/model.json");
model = lm;
const emptyInput = tf.tensor2d([[0, 0]]);
model.predict(emptyInput) as tf.Tensor;
setLoadCamera(true);
} catch (error) {
// console.error("Error loading model:", error);
}
};
const initializeHandDetection = async () => {
try {
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
);
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
},
numHands: 2,
runningMode: "VIDEO",
});
detectHands();
} catch (error) {
console.error("Error initializing hand detection:", error);
}
};
let previousResult: string[] = [];
const [progress, setProgress] = useState(0);
const makePrediction = async (finalResult: any) => {
const input = tf.tensor2d([finalResult]);
// Melakukan prediksi
const prediction = model.predict(input) as tf.Tensor;
const result = prediction.dataSync();
const maxEntry = Object.entries(result).reduce((max, entry) => {
const [, value] = entry;
return value > max[1] ? entry : max;
});
// maxEntry sekarang berisi [key, value] dengan nilai terbesar
const [maxKey] = maxEntry;
// const percentageValue = (maxValue * 100).toFixed(2) + "%";
// setResultPredict({
// abjad: ConvertResult(parseInt(maxKey)),
// acc: percentageValue,
// });
let currentResult = ConvertResult(parseInt(maxKey));
// Hapus tensor
input.dispose();
prediction.dispose();
if (
previousResult.length > 0 &&
previousResult[previousResult.length - 1] === currentResult
) {
previousResult.push(currentResult);
setProgress((prev) => prev + 10);
} else {
previousResult = [currentResult];
setProgress(10);
}
if (previousResult.length == 11) {
setShowAnswer(true);
previousResult = [];
setProgress(0);
setTimeout(() => {
setShowAnswer(false);
}, 2000);
}
// console.log(previousResult);
};
const detectHands = async () => {
if (showAnswer) {
return;
}
if (videoRef.current && videoRef.current.readyState >= 2) {
const detections = handLandmarker.detectForVideo(
videoRef.current,
performance.now()
);
setHandPresence(detections.handedness.length > 0);
// Assuming detections.landmarks is an array of landmark objects
if (detections.landmarks) {
if (detections.handednesses.length > 0) {
// console.log(detections);
if (detections.handednesses[0][0].displayName === "Right") {
const landm = detections.landmarks[0].map((landmark) => landmark);
const calt = calcLandmarkList(videoRef.current, landm);
const finalResult = preProcessLandmark(calt);
makePrediction(finalResult);
} else {
setHandPresence(false);
setProgress(0);
previousResult = [];
}
} else {
setProgress(0);
previousResult = [];
}
}
}
requestAnimationFrame(detectHands);
};
const store = useNavbarStore();
useEffect(() => {
store.setNavSelected("kuis");
loadModel();
startWebcam();
return () => {
if (handLandmarker) {
handLandmarker.close();
}
};
}, []);
return (
<LayoutPage>
<div
className={`fixed inset-0 w-screen h-screen bg-black/60 ${
showAnswer ? "opacity-100" : "opacity-0"
} z-[999] flex items-center justify-center pointer-events-none duration-300 ease-in-out`}
>
<div className="rounded-md px-3 py-2 text-white flex flex-col justify-center items-center gap-3">
<img
className="h-56"
src="/assets/gif/salah.gif"
alt="Jawaban Salah"
/>
<p className="text-center text-6xl font-bold">A</p>
<h1 className="text-2xl font-semibold text-center">
Jawaban kamu Salah
</h1>
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<MdOutlineQuiz size={18} />
<h1 className="text-xl font-semibold">Soal: 1 / 10</h1>
</div>
<div className="flex flex-col flex-1 py-4">
{loadCamera ? (
<div className="rounded-md overflow-hidden relative">
{!showAnswer && (
<div className="top-6 left-6 absolute flex gap-2 items-center bg-white text-black rounded-md drop-shadow px-3 py-2">
<h1 className="text-2xl font-semibold text-center">
Tebak Huruf K
</h1>
</div>
)}
{handPresence && !showAnswer && (
<div className="top-6 right-6 absolute flex gap-2 items-center bg-white text-black rounded-md drop-shadow px-3 py-2 w-fit">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="loader"></span>
<h1>Tahan Tangan..</h1>
</div>
<ProgressBar progress={progress} />
</div>
</div>
)}
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full z-20"
/>
<video
ref={videoRef}
className="w-full max-h-[80svh] object-cover"
autoPlay
playsInline
></video>
</div>
) : (
<div className="flex flex-col items-center justify-center flex-1">
<div className="loader"></div>
<p className="mt-4 text-lg text-gray-700">Loading...</p>
</div>
)}
</div>
</LayoutPage>
);
};
export default Quiz;

View File

@ -0,0 +1,49 @@
import LayoutPage from "@/components/templates/LayoutPage";
import { Link } from "react-router-dom";
const TebakHuruf = () => {
return (
<LayoutPage>
<div className="flex flex-col flex-1 py-4 relative">
<ul className="flex gap-3 mt-4">
<li className="hover:text-primary cursor-default">Home</li>
<li>{">"}</li>
<Link to="/kuis" className="hover:text-primary">
Kuis
</Link>
<li>{">"}</li>
<li className="font-medium text-gray-500">Tebak Huruf</li>
</ul>
<div className="flex-1 flex flex-col mt-12 md:mt-24 w-full md:w-[500px]">
<h1 className="text-5xl font-semibold">
Start Your <span className="text-primary">Quiz!</span>
</h1>
<div className="flex flex-col gap-3 w-full mt-24 md:mt-36">
<h1 className="font-semibold text-xl">Masukkan Nama</h1>
<div className="bg-[#F2F2F2] rounded-full pr-4 flex py-2 items-center">
<input
placeholder="Name.."
type="text"
className="bg-transparent outline-none p-2 px-8 flex-1 w-full"
/>
<Link to="/kuis/tebak-huruf/app">
<button className="bg-blue-500 hover:bg-blue-700 text-white px-3 py-2 rounded-full whitespace-nowrap">
Mulai Kuis
</button>
</Link>
</div>
<span className="text-primary">Lihat Ranking</span>
</div>
</div>
<img
className="absolute right-0 md:top-36 bottom-0 pointer-events-none"
src="/assets/images/overlay-bg.png"
alt="Overlay Background"
/>
</div>
</LayoutPage>
);
};
export default TebakHuruf;

View File

@ -1,26 +1,41 @@
import { lazy } from "react"; import { lazy } from "react";
const Home = lazy(() => import("@/pages/Home"));
const Kamus = lazy(() => import("@/pages/Kamus"));
const Kuis = lazy(() => import("@/pages/Kuis"));
const myRoute = [ const myRoute = [
{ {
"title": "Home", title: "Home",
"path": "/", path: "/",
"component": Home, component: lazy(() => import("@/pages/Home")),
}, },
{ {
"title": "Kamus", title: "Kamus",
"path": "/kamus", path: "/kamus",
"component": Kamus component: lazy(() => import("@/pages/Kamus")),
}, },
{ {
"title": "Kuis", title: "Kuis",
"path": "/kuis", path: "/kuis",
"component": Kuis component: lazy(() => import("@/pages/Kuis")),
}, },
{
title: "Tebak Huruf",
path: "/kuis/tebak-huruf",
component: lazy(() => import("@/pages/Kuis/TebakHuruf/TebakHuruf")),
},
{
title: "Start Quiz",
path: "/kuis/tebak-huruf/app",
component: lazy(() => import("@/pages/Kuis/TebakHuruf/Quiz")),
},
{
title: "Menyusun Huruf",
path: "/kuis/menyusun-huruf",
component: lazy(() => import("@/pages/Kuis/MenyusunHuruf/MenyusunHuruf")),
},
{
title: "Start Quiz",
path: "/kuis/menyusun-huruf/app",
component: lazy(() => import("@/pages/Kuis/MenyusunHuruf/Quiz")),
},
];
] export default myRoute;
export default myRoute;

View File

@ -0,0 +1,38 @@
import { create } from "zustand";
const soal = [
"INDONESIA",
"KUCING",
"SIBI",
"MAHASISWA",
"KSULI",
"INFORMATIKA",
"CODING",
"WHATSAPP",
"INSTAGRAM",
"TEMAN",
];
type MenyusunHurufType = {
listSoal: string[];
setListSoal: (listSoal: any[]) => void;
soalIndex: number;
setSoalIndex: (index: number) => void;
time: number;
setTime: (time: number) => void;
session: boolean;
setSession: (session: boolean) => void;
};
const useMenyusunHurufStore = create<MenyusunHurufType>((set) => ({
listSoal: soal,
setListSoal: (listSoal) => set({ listSoal: listSoal }),
soalIndex: 0,
setSoalIndex: (index) => set({ soalIndex: index }),
time: 0,
setTime: (time) => set({ time: time }),
session: false,
setSession: (session) => set({ session: session }),
}));
export default useMenyusunHurufStore;

View File

@ -0,0 +1,33 @@
import { create } from "zustand";
type JawabanType = {
jawaban: string;
isCorrect: boolean;
};
type TebakHurufType = {
listSoal: any[];
setListSoal: (listSoal: any[]) => void;
soalIndex: number;
setSoalIndex: (index: number) => void;
score: number;
setScore: (score: number) => void;
jawaban: JawabanType[];
setJawaban: (jawaban: JawabanType[]) => void;
addJawaban: (jawaban: JawabanType) => void;
};
const useTebakHurufStore = create<TebakHurufType>((set) => ({
listSoal: [],
setListSoal: (listSoal) => set({ listSoal: listSoal }),
soalIndex: 0,
setSoalIndex: (index) => set({ soalIndex: index }),
score: 0,
setScore: (score) => set({ score: score }),
jawaban: [],
setJawaban: (jawaban) => set({ jawaban: jawaban }),
addJawaban: (jawaban) =>
set((state) => ({ jawaban: [...state.jawaban, jawaban] })),
}));
export default useTebakHurufStore;