Spaces:
Running
Running
<html lang="id"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Simulasi Interaktif U-Net</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
<style> | |
/* Custom styles */ | |
body { font-family: 'Inter', sans-serif; overflow: hidden; /* Prevent scrolling */ } | |
#canvas-container { width: 100%; height: 100vh; position: relative; } /* Full viewport height */ | |
#info-panel { | |
position: absolute; | |
top: 1rem; | |
left: 1rem; | |
background-color: rgba(0, 0, 0, 0.7); | |
color: white; | |
padding: 0.75rem; | |
border-radius: 0.5rem; | |
max-width: 250px; /* Limit width */ | |
font-size: 0.875rem; /* text-sm */ | |
z-index: 10; | |
} | |
#controls { | |
position: absolute; | |
bottom: 1rem; | |
left: 50%; | |
transform: translateX(-50%); | |
background-color: rgba(0, 0, 0, 0.7); | |
padding: 0.5rem 1rem; | |
border-radius: 0.5rem; | |
display: flex; | |
gap: 0.5rem; /* Space between buttons */ | |
z-index: 10; | |
} | |
.control-button { | |
background-color: #4a5568; /* gray-700 */ | |
color: white; | |
padding: 0.5rem 1rem; | |
border-radius: 0.375rem; /* rounded-md */ | |
border: none; | |
cursor: pointer; | |
transition: background-color 0.2s; | |
} | |
.control-button:hover { | |
background-color: #2d3748; /* gray-800 */ | |
} | |
.control-button:disabled { | |
background-color: #a0aec0; /* gray-500 */ | |
cursor: not-allowed; | |
} | |
/* Style for highlighting active block */ | |
.active-block { | |
/* Example styles - choose what looks best */ | |
border: 2px solid yellow; /* Tambahkan border kuning */ | |
box-shadow: 0 0 10px yellow; /* Tambahkan efek glow kuning */ | |
/* Anda juga bisa mengubah background-color atau properti lainnya */ | |
} | |
/* Style for skip connection visualization (Handled by Three.js) */ | |
#feature-map-display { | |
position: absolute; | |
top: 1rem; | |
right: 1rem; | |
background-color: rgba(255, 255, 255, 0.9); | |
border: 1px solid #ccc; | |
border-radius: 0.5rem; | |
padding: 0.5rem; | |
width: 150px; /* Adjust as needed */ | |
height: 150px; /* Adjust as needed */ | |
z-index: 10; | |
display: none; /* Hidden by default */ | |
font-size: 0.75rem; | |
color: #333; | |
text-align: center; | |
} | |
#feature-map-display canvas { | |
display: block; | |
margin: 5px auto 0; | |
max-width: 100%; | |
height: auto; | |
} | |
/* Style for Tooltip */ | |
#tooltip { | |
position: absolute; | |
display: none; /* Hidden by default */ | |
background-color: rgba(0, 0, 0, 0.8); | |
color: white; | |
padding: 5px 10px; | |
border-radius: 4px; | |
font-size: 0.8rem; | |
white-space: pre-wrap; /* Allow line breaks */ | |
z-index: 100; /* Ensure it's on top */ | |
pointer-events: none; /* Prevent tooltip from blocking mouse events */ | |
max-width: 200px; | |
} | |
/* Style for Modal */ | |
#detail-modal-backdrop { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0, 0, 0, 0.6); /* Semi-transparent black */ | |
z-index: 40; /* Below modal, above other content */ | |
display: none; /* Hidden by default */ | |
} | |
#detail-modal { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: #2d3748; /* gray-800 */ | |
color: white; | |
padding: 1.5rem; /* p-6 */ | |
border-radius: 0.5rem; /* rounded-lg */ | |
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg */ | |
z-index: 50; /* Above backdrop */ | |
display: none; /* Hidden by default */ | |
min-width: 300px; | |
max-width: 90%; /* Responsive max width */ | |
} | |
#detail-modal h4 { | |
font-size: 1.125rem; /* text-lg */ | |
font-weight: 600; /* font-semibold */ | |
margin-bottom: 1rem; /* mb-4 */ | |
} | |
#detail-modal p { | |
font-size: 0.875rem; /* text-sm */ | |
margin-bottom: 0.5rem; /* mb-2 */ | |
white-space: pre-wrap; /* Respect newlines in info */ | |
} | |
#modal-close-button { | |
position: absolute; | |
top: 0.5rem; /* top-2 */ | |
right: 0.5rem; /* right-2 */ | |
background: none; | |
border: none; | |
color: #a0aec0; /* gray-500 */ | |
font-size: 1.5rem; /* text-2xl */ | |
line-height: 1; | |
cursor: pointer; | |
} | |
#modal-close-button:hover { | |
color: #e2e8f0; /* gray-300 */ | |
} | |
/* Style for Quiz Modal */ | |
#quiz-modal-backdrop { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0, 0, 0, 0.7); /* Darker backdrop */ | |
z-index: 60; /* Above detail modal backdrop */ | |
display: none; /* Hidden by default */ | |
} | |
#quiz-modal { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: #1a202c; /* gray-900 */ | |
color: white; | |
padding: 1.5rem 2rem; /* p-6 px-8 */ | |
border-radius: 0.75rem; /* rounded-xl */ | |
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.1); /* shadow-lg */ | |
z-index: 70; /* Above backdrop */ | |
display: none; /* Hidden by default */ | |
min-width: 400px; | |
max-width: 600px; /* Wider for questions */ | |
width: 90%; | |
} | |
#quiz-modal h4 { | |
font-size: 1.25rem; /* text-xl */ | |
font-weight: 600; /* font-semibold */ | |
margin-bottom: 1.5rem; /* mb-6 */ | |
text-align: center; | |
color: #a0aec0; /* gray-400 */ | |
} | |
#quiz-question-container { | |
margin-bottom: 1.5rem; /* mb-6 */ | |
} | |
#quiz-question { | |
font-size: 1rem; /* text-base */ | |
margin-bottom: 1rem; /* mb-4 */ | |
line-height: 1.5; | |
} | |
#quiz-options { | |
display: flex; | |
flex-direction: column; | |
gap: 0.75rem; /* space-y-3 */ | |
} | |
.quiz-option label { | |
display: flex; | |
align-items: center; | |
background-color: #2d3748; /* gray-800 */ | |
padding: 0.75rem 1rem; /* py-3 px-4 */ | |
border-radius: 0.375rem; /* rounded-md */ | |
cursor: pointer; | |
transition: background-color 0.2s; | |
font-size: 0.875rem; /* text-sm */ | |
} | |
.quiz-option label:hover { | |
background-color: #4a5568; /* gray-700 */ | |
} | |
.quiz-option input[type="checkbox"] { | |
margin-right: 0.75rem; /* mr-3 */ | |
accent-color: #4299e1; /* blue-500 */ | |
width: 1rem; | |
height: 1rem; | |
} | |
#quiz-feedback { | |
margin-top: 1rem; /* mt-4 */ | |
font-size: 0.875rem; /* text-sm */ | |
min-height: 1.25rem; /* Ensure space for feedback */ | |
text-align: center; | |
} | |
#quiz-feedback.correct { | |
color: #48bb78; /* green-500 */ | |
font-weight: 600; | |
} | |
#quiz-feedback.incorrect { | |
color: #f56565; /* red-500 */ | |
font-weight: 600; | |
} | |
#quiz-score { | |
text-align: right; | |
font-size: 0.875rem; /* text-sm */ | |
color: #a0aec0; /* gray-500 */ | |
margin-bottom: 1rem; /* mb-4 */ | |
} | |
#quiz-controls { | |
display: flex; | |
justify-content: space-between; /* Adjust layout */ | |
align-items: center; | |
margin-top: 1.5rem; /* mt-6 */ | |
} | |
/* #quiz-submit-button uses .control-button class */ | |
#quiz-close-button { | |
background: none; | |
border: none; | |
color: #a0aec0; /* gray-500 */ | |
font-size: 1rem; | |
cursor: pointer; | |
} | |
#quiz-close-button:hover { | |
color: #e2e8f0; /* gray-300 */ | |
} | |
#quiz-results { | |
text-align: center; | |
margin-top: 2rem; /* mt-8 */ | |
} | |
#quiz-results h5 { | |
font-size: 1.125rem; /* text-lg */ | |
font-weight: 600; | |
margin-bottom: 0.5rem; /* mb-2 */ | |
} | |
#quiz-results p { | |
font-size: 1rem; /* text-base */ | |
color: #cbd5e0; /* gray-400 */ | |
} | |
#back-link { | |
display: block; | |
margin-top: 0.75rem; /* Add some space above */ | |
color: #63b3ed; /* blue-400 */ | |
text-decoration: none; | |
font-size: 0.8rem; | |
} | |
#back-link:hover { | |
color: #90cdf4; /* blue-300 */ | |
text-decoration: underline; | |
} | |
</style> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> | |
</head> | |
<body class="bg-gray-900"> | |
<div id="canvas-container"> | |
<div id="info-panel"> | |
<h3 class="text-lg font-semibold mb-2">Informasi Simulasi</h3> | |
<p><strong>Input Gambar:</strong> <span id="input-name">-</span></p> | |
<p><strong>Langkah Saat Ini:</strong> <span id="current-step">-</span></p> | |
<p><strong>Dimensi Data:</strong> <span id="data-dimensions">-</span></p> | |
<p class="mt-2 text-xs">Gunakan mouse untuk memutar (klik kiri), zoom (scroll), dan pan (klik kanan).</p> | |
<p class="mt-1 text-xs">Klik pada blok untuk info detail.</p> | |
<a href="index.html" id="back-link">← Kembali ke Showcase</a> <!-- Added Back Link --> | |
</div> | |
<div id="controls"> | |
<select id="input-select" class="control-button bg-gray-700 hover:bg-gray-800 text-white rounded-md px-3 py-1.5 text-sm"> | |
<option value="sample1">Input Sampel 1</option> | |
<option value="sample2">Input Sampel 2</option> | |
</select> | |
<button id="reset-button" class="control-button">Reset</button> | |
<button id="prev-step-button" class="control-button" disabled>← Sebelumnya</button> | |
<button id="next-step-button" class="control-button">Selanjutnya →</button> | |
<button id="run-auto-button" class="control-button">Jalankan Otomatis</button> | |
<button id="quiz-button" class="control-button">Quiz</button> <!-- New Quiz Button --> | |
</div> | |
<div id="feature-map-display"> | |
<p>Feature Map (Visualisasi)</p> | |
<canvas id="feature-map-canvas" width="128" height="128"></canvas> | |
</div> | |
</div> | |
<!-- Modal Structure --> | |
<div id="detail-modal-backdrop"></div> | |
<div id="detail-modal"> | |
<button id="modal-close-button">×</button> | |
<h4 id="modal-title">Detail Blok</h4> | |
<p><strong>Nama:</strong> <span id="modal-block-name">-</span></p> | |
<p><strong>Dimensi Data:</strong> <span id="modal-block-dimensions">-</span></p> | |
<p><strong>Deskripsi:</strong></p> | |
<p id="modal-block-description">-</p> | |
<!-- Add more detail fields if needed --> | |
</div> | |
<!-- Quiz Modal Structure --> | |
<div id="quiz-modal-backdrop"></div> | |
<div id="quiz-modal"> | |
<h4>U-Net Quiz</h4> | |
<div id="quiz-score">Skor: 0/0</div> | |
<div id="quiz-question-container"> | |
<p id="quiz-question">Memuat pertanyaan...</p> | |
<div id="quiz-options"> | |
<!-- Options will be loaded here --> | |
</div> | |
<div id="quiz-feedback"></div> | |
</div> | |
<div id="quiz-results" style="display: none;"> | |
<h5>Quiz Selesai!</h5> | |
<p id="quiz-final-score"></p> | |
</div> | |
<div id="quiz-controls"> | |
<button id="quiz-close-button">Tutup</button> | |
<div> <!-- Group navigation/submit buttons --> | |
<button id="quiz-back-button" class="control-button" disabled>← Back</button> | |
<button id="quiz-submit-button" class="control-button">Submit Jawaban</button> | |
<button id="quiz-next-button" class="control-button">Next →</button> | |
</div> | |
</div> | |
</div> | |
<!-- Tooltip Element --> | |
<div id="tooltip"></div> | |
<script> | |
// --- Konfigurasi Dasar Three.js --- | |
let scene, camera, renderer, controls; | |
let raycaster, mouse; | |
const container = document.getElementById('canvas-container'); | |
const infoPanel = { | |
step: document.getElementById('current-step'), | |
dimensions: document.getElementById('data-dimensions'), | |
inputName: document.getElementById('input-name'), | |
}; | |
const featureMapDisplay = document.getElementById('feature-map-display'); | |
const featureMapCanvas = document.getElementById('feature-map-canvas'); | |
const featureMapCtx = featureMapCanvas.getContext('2d'); | |
let tooltipElement; // Variable for the tooltip | |
let modalBackdrop, modalElement, modalTitle, modalBlockName, modalBlockDimensions, modalBlockDescription, modalCloseButton; | |
let quizModalBackdrop, quizModal, quizButton, quizQuestionEl, quizOptionsEl, quizFeedbackEl, quizScoreEl, quizSubmitButton, quizCloseButton, quizResultsEl, quizFinalScoreEl, quizQuestionContainer; // Added quizQuestionContainer | |
let quizBackButton, quizNextButton; // Add variables for new buttons | |
// --- State Simulasi --- | |
let currentStepIndex = -1; // Belum dimulai | |
let simulationSteps = []; // Akan diisi dengan info tiap langkah | |
let networkObjects = {}; // Menyimpan objek Three.js untuk tiap layer/blok | |
let skipConnectionLines = []; // Menyimpan garis untuk skip connections | |
let isAutoRunning = false; | |
let autoRunInterval = null; | |
const defaultBlockColor = 0x0077cc; // Biru | |
const activeBlockColor = 0xffcc00; // Kuning | |
const skipConnectionColor = 0x00ff00; // Hijau | |
let isQuizActive = false; | |
let currentQuizQuestionIndex = 0; | |
let quizScore = 0; | |
let quizQuestions = []; // Will be populated in init | |
let quizAnswerSubmitted = false; // Track if current answer was submitted | |
// --- Data Sampel (Placeholder) --- | |
const sampleInputs = { | |
sample1: { name: "Sel Darah (Contoh)", initialDim: "128x128x3" }, | |
sample2: { name: "Citra Medis (Contoh)", initialDim: "256x256x1" }, | |
}; | |
let currentInput = sampleInputs.sample1; // Default | |
// --- Elemen Kontrol --- | |
const inputSelect = document.getElementById('input-select'); | |
const resetButton = document.getElementById('reset-button'); | |
const prevStepButton = document.getElementById('prev-step-button'); | |
const nextStepButton = document.getElementById('next-step-button'); | |
const runAutoButton = document.getElementById('run-auto-button'); | |
// --- Inisialisasi --- | |
function init() { | |
// 1. Scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x1a202c); // Warna latar belakang gelap (gray-900) | |
// 2. Camera | |
camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000); | |
camera.position.z = 15; // Mundurkan kamera | |
camera.position.y = 5; // Naikkan sedikit | |
// 3. Renderer | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(container.clientWidth, container.clientHeight); | |
container.appendChild(renderer.domElement); | |
// 4. Controls | |
controls = new THREE.OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; // Efek halus saat rotasi | |
controls.dampingFactor = 0.1; | |
controls.screenSpacePanning = false; // Batasi panning pada bidang xz | |
// 5. Lighting | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(5, 10, 7.5); | |
scene.add(directionalLight); | |
// 6. Raycaster for interaction | |
raycaster = new THREE.Raycaster(); | |
mouse = new THREE.Vector2(); | |
// --- MOVED: Get Control, Tooltip and Modal Elements --- | |
// Ensure these are defined before resetSimulation is called | |
tooltipElement = document.getElementById('tooltip'); | |
modalBackdrop = document.getElementById('detail-modal-backdrop'); | |
modalElement = document.getElementById('detail-modal'); | |
modalTitle = document.getElementById('modal-title'); | |
modalBlockName = document.getElementById('modal-block-name'); | |
modalBlockDimensions = document.getElementById('modal-block-dimensions'); | |
modalBlockDescription = document.getElementById('modal-block-description'); | |
modalCloseButton = document.getElementById('modal-close-button'); | |
quizModalBackdrop = document.getElementById('quiz-modal-backdrop'); | |
quizModal = document.getElementById('quiz-modal'); | |
quizButton = document.getElementById('quiz-button'); // Assign quizButton here | |
quizQuestionEl = document.getElementById('quiz-question'); | |
quizOptionsEl = document.getElementById('quiz-options'); | |
quizFeedbackEl = document.getElementById('quiz-feedback'); | |
quizScoreEl = document.getElementById('quiz-score'); | |
quizSubmitButton = document.getElementById('quiz-submit-button'); | |
quizCloseButton = document.getElementById('quiz-close-button'); | |
quizResultsEl = document.getElementById('quiz-results'); | |
quizFinalScoreEl = document.getElementById('quiz-final-score'); | |
quizQuestionContainer = document.getElementById('quiz-question-container'); // Get question container | |
quizBackButton = document.getElementById('quiz-back-button'); // Get back button | |
quizNextButton = document.getElementById('quiz-next-button'); // Get next button | |
// --- END MOVED SECTION --- | |
// --- Add Listeners for Modals (Detail & Quiz Internal) --- | |
// Add listeners to close detail modal | |
modalCloseButton.addEventListener('click', hideModal); | |
modalBackdrop.addEventListener('click', hideModal); // Close on backdrop click | |
// Add Quiz Modal Internal Listeners (Added only ONCE here) | |
quizCloseButton.addEventListener('click', endQuiz); | |
quizModalBackdrop.addEventListener('click', endQuiz); // Also close on backdrop click | |
quizSubmitButton.addEventListener('click', handleQuizSubmit); | |
quizBackButton.addEventListener('click', handleQuizBack); // Add listener for back | |
quizNextButton.addEventListener('click', handleQuizNext); // Add listener for next | |
// --- END Add Listeners --- | |
// Disable Quiz button initially | |
if (quizButton) { // Check if quizButton exists before disabling | |
quizButton.disabled = true; | |
quizButton.textContent = "Memuat Quiz..."; // Indicate loading | |
} | |
// 7. Build the U-Net visualization & Set Initial State | |
buildUNetArchitecture(); | |
addLabels(); // Panggil fungsi untuk menambahkan label setelah arsitektur dibuat | |
defineSimulationSteps(); // Definisikan langkah simulasi setelah arsitektur dibuat | |
resetSimulation(); // Set ke state awal - NOW safe to call updateButtonStates | |
// 8. Event Listeners (Simulation Controls) | |
window.addEventListener('resize', onWindowResize, false); | |
container.addEventListener('click', onCanvasClick, false); // Re-enable click listener | |
container.addEventListener('mousemove', onCanvasMouseMove, false); // Add mousemove listener | |
resetButton.addEventListener('click', resetSimulation); | |
prevStepButton.addEventListener('click', previousStep); | |
nextStepButton.addEventListener('click', nextStep); | |
runAutoButton.addEventListener('click', toggleAutoRun); | |
inputSelect.addEventListener('change', handleInputChange); | |
// REMOVED Quiz Listeners from here - Moved up or added in fetch | |
// --- Load Quiz Questions --- | |
fetch('quiz_questions.json') // Path relative to the HTML file | |
.then(response => { | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
quizQuestions = data; | |
console.log("Quiz questions loaded successfully:", quizQuestions.length, "questions"); | |
// Enable Quiz button and set up its main listener only after loading | |
if (quizButton && quizQuestions.length > 0) { | |
quizButton.disabled = false; | |
quizButton.textContent = "Quiz"; | |
quizButton.addEventListener('click', startQuiz); // Add listener for the main quiz button HERE | |
updateQuizScoreDisplay(); // Initialize score display (0/N) | |
} else if (quizButton) { | |
quizButton.textContent = "Quiz Kosong"; | |
console.warn("Quiz questions loaded, but the array is empty."); | |
} | |
}) | |
.catch(error => { | |
console.error("Error loading quiz questions:", error); | |
if (quizButton) { | |
quizButton.textContent = "Quiz Gagal Dimuat"; | |
// Keep button disabled | |
} | |
// Optionally display an error message to the user in the quiz modal later | |
quizQuestionEl.textContent = "Gagal memuat pertanyaan quiz. Periksa file 'quiz_questions.json'."; | |
}); | |
// Start the animation loop | |
animate(); | |
} | |
// --- Helper Function to Create Text Labels (Sprites) --- | |
function createLabel(text, position, color = '#ffffff', fontSize = 48, backgroundPadding = 10) { | |
const canvas = document.createElement('canvas'); | |
const context = canvas.getContext('2d'); | |
context.font = `Bold ${fontSize}px Arial`; | |
// Ukur teks untuk menentukan ukuran canvas | |
const textMetrics = context.measureText(text); | |
const textWidth = textMetrics.width; | |
canvas.width = textWidth + backgroundPadding * 2; | |
canvas.height = fontSize + backgroundPadding * 2; | |
// Gambar background (opsional, bisa transparan) | |
// context.fillStyle = 'rgba(0, 0, 0, 0.5)'; // Background semi-transparan | |
// context.fillRect(0, 0, canvas.width, canvas.height); | |
// Gambar teks | |
context.font = `Bold ${fontSize}px Arial`; // Set ulang font setelah resize canvas | |
context.fillStyle = color; | |
context.textAlign = 'center'; | |
context.textBaseline = 'middle'; | |
context.fillText(text, canvas.width / 2, canvas.height / 2); | |
const texture = new THREE.CanvasTexture(canvas); | |
texture.needsUpdate = true; | |
const material = new THREE.SpriteMaterial({ map: texture, transparent: true }); | |
const sprite = new THREE.Sprite(material); | |
// Skala sprite agar sesuai dengan scene | |
const scaleFactor = 0.01; // Sesuaikan nilai ini untuk ukuran label | |
sprite.scale.set(canvas.width * scaleFactor, canvas.height * scaleFactor, 1); | |
sprite.position.copy(position); | |
scene.add(sprite); | |
return sprite; | |
} | |
// --- Menambahkan Label ke Scene --- | |
function addLabels() { | |
const labelYOffset = 4; // Jarak vertikal label dari blok teratas | |
const encoderTopY = networkObjects['Encoder_1'].position.y; | |
const decoderTopY = networkObjects['Decoder_4'].position.y; | |
const inputY = networkObjects['Input_Image'].position.y; | |
const outputY = networkObjects['Output_Segmentation'].position.y; | |
// Label Input | |
createLabel("Input", new THREE.Vector3( | |
networkObjects['Input_Image'].position.x, | |
inputY + labelYOffset, | |
0 | |
)); | |
// Label Encoder Path | |
createLabel("Encoder Path", new THREE.Vector3( | |
networkObjects['Encoder_1'].position.x, // X dari blok encoder pertama | |
encoderTopY + labelYOffset, // Y di atas blok encoder pertama | |
0 | |
)); | |
// Label Bottleneck | |
createLabel("Bottleneck", new THREE.Vector3( | |
networkObjects['Bottleneck'].position.x, | |
networkObjects['Bottleneck'].position.y + labelYOffset * 0.6, // Sedikit lebih dekat ke bottleneck | |
0 | |
)); | |
// Label Decoder Path | |
createLabel("Decoder Path", new THREE.Vector3( | |
networkObjects['Decoder_4'].position.x, // X dari blok decoder terakhir (paling atas) | |
decoderTopY + labelYOffset, // Y di atas blok decoder terakhir | |
0 | |
)); | |
// Label Output | |
createLabel("Output", new THREE.Vector3( | |
networkObjects['Output_Segmentation'].position.x, | |
outputY + labelYOffset, | |
0 | |
)); | |
} | |
// --- Membangun Visualisasi Arsitektur U-Net --- | |
function buildUNetArchitecture() { | |
// Ukuran dan posisi relatif (bisa disesuaikan) | |
const blockDepth = 0.5; | |
const levelHeight = 3; // Jarak vertikal antar level | |
const horizontalSpacing = 4; // Jarak horizontal antar blok di level yang sama | |
const contractingPathX = -horizontalSpacing * 1.5; | |
const expandingPathX = horizontalSpacing * 1.5; | |
// Helper function to create a block (layer group) | |
function createBlock(name, width, height, color = defaultBlockColor) { | |
const geometry = new THREE.BoxGeometry(width, height, blockDepth); | |
const material = new THREE.MeshStandardMaterial({ color: color, metalness: 0.3, roughness: 0.6 }); | |
const mesh = new THREE.Mesh(geometry, material); | |
mesh.name = name; // Nama untuk identifikasi | |
// --- Enhanced userData.info --- | |
let description = "Informasi detail belum tersedia."; | |
if (name.startsWith("Encoder")) { | |
description = `Blok Encoder (${name.split('_')[1]}):\n- Bagian ini (Contracting Path) secara bertahap mengekstrak fitur dari gambar input sambil mengurangi ukurannya (resolusi spasial).\n- Dalam simulasi ini: Melakukan 2x (Konvolusi 3x3 + ReLU) untuk deteksi pola, diikuti Max Pooling 2x2 (kecuali blok pertama) untuk mengurangi dimensi spasial (misal, 128x128 -> 64x64) dan biasanya menggandakan jumlah fitur (channels).\n- Fitur yang diekstrak di level ini disimpan untuk Skip Connection ke blok Decoder yang sesuai.`; | |
} else if (name === "Bottleneck") { | |
description = "Bottleneck:\n- Lapisan terdalam dan terkecil dalam arsitektur U-Net, menghubungkan Encoder dan Decoder.\n- Dalam simulasi ini: Menerima output dari Encoder terakhir setelah Max Pooling, lalu melakukan 2x (Konvolusi 3x3 + ReLU).\n- Menangkap informasi kontekstual tingkat tinggi (fitur paling abstrak) pada resolusi terendah (misal, 8x8)."; | |
} else if (name.startsWith("Decoder")) { | |
description = `Blok Decoder (${name.split('_')[1]}):\n- Bagian ini (Expanding Path) secara bertahap membangun kembali peta segmentasi, meningkatkan resolusi spasial.\n- Dalam simulasi ini: Melakukan Up-Convolution 2x2 (Transposed Convolution) untuk menggandakan dimensi spasial (misal, 16x16 -> 32x32) dan biasanya mengurangi separuh jumlah fitur.\n- Menggabungkan (concatenate) hasilnya dengan fitur dari Skip Connection Encoder yang sesuai (misal, Decoder 1 dengan Encoder 4).\n- Melakukan 2x (Konvolusi 3x3 + ReLU) pada fitur gabungan untuk menyempurnakan representasi.`; | |
} else if (name === "Output_Segmentation") { | |
description = "Output Layer:\n- Lapisan terakhir dari U-Net yang menghasilkan peta segmentasi akhir.\n- Dalam simulasi ini: Menerima input dari blok Decoder terakhir (misal, 128x128x64) dan melakukan Konvolusi 1x1 untuk memetakan fitur ke jumlah kelas yang diinginkan (disini 1 channel untuk segmentasi biner, menghasilkan output 128x128x1).\n- Setiap piksel pada output mewakili probabilitas kelas (misal, objek vs latar belakang)."; | |
} else if (name === "Input_Image") { | |
description = "Input:\n- Gambar asli yang dimasukkan ke dalam jaringan U-Net.\n- Dalam simulasi ini: Dimensi awal (misal, 128x128x3 atau 256x256x1) ditentukan oleh pilihan input sampel."; | |
} | |
mesh.userData = { | |
info: description, // Use the detailed description | |
dimensions: "N/A" // Akan diupdate saat simulasi | |
}; | |
scene.add(mesh); | |
networkObjects[name] = mesh; | |
return mesh; | |
} | |
// --- Contracting Path (Encoder) --- | |
const enc1 = createBlock("Encoder_1", 3, 3); | |
enc1.position.set(contractingPathX, levelHeight * 2, 0); | |
const enc2 = createBlock("Encoder_2", 2.5, 2.5); | |
enc2.position.set(contractingPathX, levelHeight * 1, 0); | |
const enc3 = createBlock("Encoder_3", 2, 2); | |
enc3.position.set(contractingPathX, levelHeight * 0, 0); | |
const enc4 = createBlock("Encoder_4", 1.5, 1.5); | |
enc4.position.set(contractingPathX, -levelHeight * 1, 0); | |
// --- Bottleneck --- | |
const bottleneck = createBlock("Bottleneck", 1, 1); | |
bottleneck.position.set(0, -levelHeight * 2, 0); | |
// --- Expanding Path (Decoder) --- | |
const dec1 = createBlock("Decoder_1", 1.5, 1.5); | |
dec1.position.set(expandingPathX, -levelHeight * 1, 0); | |
const dec2 = createBlock("Decoder_2", 2, 2); | |
dec2.position.set(expandingPathX, levelHeight * 0, 0); | |
const dec3 = createBlock("Decoder_3", 2.5, 2.5); | |
dec3.position.set(expandingPathX, levelHeight * 1, 0); | |
const dec4 = createBlock("Decoder_4", 3, 3); | |
dec4.position.set(expandingPathX, levelHeight * 2, 0); | |
// --- Output Layer --- | |
const output = createBlock("Output_Segmentation", 3, 3, 0x999999); // Warna abu-abu | |
output.position.set(expandingPathX + horizontalSpacing, levelHeight * 2, 0); | |
// --- Input Layer (Visual Placeholder) --- | |
const inputVis = createBlock("Input_Image", 3, 3, 0xcccccc); // Warna abu-abu terang | |
inputVis.position.set(contractingPathX - horizontalSpacing, levelHeight * 2, 0); | |
networkObjects["Input_Image"] = inputVis; // Tambahkan ke networkObjects | |
// Atur posisi kamera agar fokus ke tengah arsitektur | |
camera.lookAt(scene.position); | |
} | |
// --- Mendefinisikan Langkah-langkah Simulasi --- | |
function defineSimulationSteps() { | |
// Urutan nama blok sesuai aliran data | |
// Format: { blockName: "NamaBlok", dimension: "HxWx C", featureMapVis: function() } | |
simulationSteps = [ | |
{ blockName: "Input_Image", dimension: currentInput.initialDim, info: "Gambar Input Awal", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Encoder_1", dimension: "128x128x64", info: "Encoder Blok 1 (Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Encoder_2", dimension: "64x64x128", info: "Encoder Blok 2 (MaxPool, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Encoder_3", dimension: "32x32x256", info: "Encoder Blok 3 (MaxPool, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Encoder_4", dimension: "16x16x512", info: "Encoder Blok 4 (MaxPool, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Bottleneck", dimension: "8x8x1024", info: "Bottleneck (MaxPool, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
// Skip connection 1 (Enc4 -> Dec1) | |
{ blockName: "Decoder_1", skipSource: "Encoder_4", dimension: "16x16x512", info: "Decoder Blok 1 (UpConv, Concat, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
// Skip connection 2 (Enc3 -> Dec2) | |
{ blockName: "Decoder_2", skipSource: "Encoder_3", dimension: "32x32x256", info: "Decoder Blok 2 (UpConv, Concat, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
// Skip connection 3 (Enc2 -> Dec3) | |
{ blockName: "Decoder_3", skipSource: "Encoder_2", dimension: "64x64x128", info: "Decoder Blok 3 (UpConv, Concat, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
// Skip connection 4 (Enc1 -> Dec4) | |
{ blockName: "Decoder_4", skipSource: "Encoder_1", dimension: "128x128x64", info: "Decoder Blok 4 (UpConv, Concat, Conv, ReLU)", featureMapVis: generateSimpleFeatureMap }, | |
{ blockName: "Output_Segmentation", dimension: "128x128x1", info: "Output Akhir (Segmentasi)", featureMapVis: generateSegmentationMap } // Misal 1 channel output | |
]; | |
} | |
// --- Fungsi Simulasi --- | |
function resetSimulation() { | |
if (isQuizActive) return; // Don't reset if quiz is active | |
stopAutoRun(); // Hentikan auto run jika sedang berjalan | |
currentStepIndex = -1; // Kembali ke sebelum start | |
clearHighlightsAndConnections(); | |
updateInfoPanel(); | |
// Set input visual dimension | |
if (networkObjects["Input_Image"]) { | |
networkObjects["Input_Image"].userData.dimensions = currentInput.initialDim; | |
} | |
featureMapDisplay.style.display = 'none'; // Sembunyikan display feature map | |
// Also reset quiz state if needed, though endQuiz handles most of it | |
isQuizActive = false; // Ensure quiz is marked as inactive | |
hideQuizModal(); // Ensure quiz modal is hidden on full reset | |
updateButtonStates(); // Update buttons after ensuring quiz is inactive | |
} | |
function nextStep() { | |
if (isQuizActive) return; // Don't allow simulation steps during quiz | |
if (currentStepIndex < simulationSteps.length - 1) { | |
currentStepIndex++; | |
updateSimulationState(); | |
} | |
updateButtonStates(); | |
} | |
function previousStep() { | |
if (isQuizActive) return; // Don't allow simulation steps during quiz | |
if (currentStepIndex > 0) { // Tidak bisa kembali sebelum langkah pertama | |
currentStepIndex--; | |
updateSimulationState(); | |
} else if (currentStepIndex === 0) { // Kembali dari langkah pertama ke state awal | |
resetSimulation(); // Reset penuh jika kembali dari langkah pertama | |
} | |
updateButtonStates(); | |
} | |
function updateSimulationState() { | |
clearHighlightsAndConnections(); // Hapus highlight & garis sebelumnya | |
const step = simulationSteps[currentStepIndex]; | |
const currentBlock = networkObjects[step.blockName]; | |
if (currentBlock) { | |
// 1. Highlight block aktif (Hanya dengan glow) | |
// currentBlock.material.color.setHex(activeBlockColor); // <-- Tidak mengubah warna dasar | |
currentBlock.material.emissive.setHex(activeBlockColor); // Tetap buat memancarkan cahaya kuning | |
currentBlock.material.emissiveIntensity = 0.6; // Naikkan intensitas glow sedikit | |
// 2. Update info dimensi di userData block (untuk hover/klik nanti) | |
currentBlock.userData.dimensions = step.dimension; | |
// 3. Update info panel utama | |
updateInfoPanel(step); | |
// 4. Tampilkan visualisasi feature map | |
if (step.featureMapVis) { | |
step.featureMapVis(featureMapCtx, step.dimension); // Panggil fungsi visualisasi | |
featureMapDisplay.style.display = 'block'; | |
} else { | |
featureMapDisplay.style.display = 'none'; | |
} | |
// 5. Gambar skip connection jika ada | |
if (step.skipSource) { | |
const sourceBlock = networkObjects[step.skipSource]; | |
if (sourceBlock) { | |
drawSkipConnection(sourceBlock, currentBlock); | |
} | |
} | |
} else { | |
console.warn(`Objek untuk blok "${step.blockName}" tidak ditemukan.`); | |
featureMapDisplay.style.display = 'none'; // Sembunyikan jika blok tidak ada | |
} | |
} | |
function clearHighlightsAndConnections() { | |
// Reset warna dan glow semua block | |
for (const name in networkObjects) { | |
const obj = networkObjects[name]; | |
// Set warna dasar berdasarkan tipe blok (pastikan ini benar) | |
if (name === "Input_Image") { | |
obj.material.color.setHex(0xcccccc); // Abu-abu terang untuk input | |
} else if (name === "Output_Segmentation") { | |
obj.material.color.setHex(0x999999); // Abu-abu untuk output | |
} else { | |
obj.material.color.setHex(defaultBlockColor); // Biru default untuk blok proses | |
} | |
obj.material.emissive.setHex(0x000000); // Matikan glow | |
obj.material.emissiveIntensity = 0; | |
} | |
// Hapus garis skip connection | |
skipConnectionLines.forEach(line => scene.remove(line)); | |
skipConnectionLines = []; | |
} | |
function drawSkipConnection(sourceBlock, targetBlock) { | |
const points = []; | |
// Titik awal sedikit di depan source block | |
const startPoint = sourceBlock.position.clone(); | |
startPoint.z += 0.3; // Sedikit offset Z | |
// Titik akhir sedikit di depan target block | |
const endPoint = targetBlock.position.clone(); | |
endPoint.z += 0.3; // Sedikit offset Z | |
// Titik tengah untuk membuat lengkungan (opsional) | |
const midPointX = (startPoint.x + endPoint.x) / 2; | |
const midPointY = (startPoint.y + endPoint.y) / 2; | |
const midPointZ = startPoint.z + 2; // Lengkungkan ke depan | |
// Gunakan kurva untuk garis melengkung | |
const curve = new THREE.QuadraticBezierCurve3( | |
startPoint, | |
new THREE.Vector3(midPointX, midPointY, midPointZ), | |
endPoint | |
); | |
const curvePoints = curve.getPoints( 50 ); // Jumlah segmen garis | |
const geometry = new THREE.BufferGeometry().setFromPoints( curvePoints ); | |
const material = new THREE.LineBasicMaterial( { color: skipConnectionColor, linewidth: 2 } ); // Buat garis lebih tebal | |
const line = new THREE.Line( geometry, material ); | |
scene.add(line); | |
skipConnectionLines.push(line); // Simpan referensi untuk dihapus nanti | |
} | |
function updateInfoPanel(step = null) { | |
if (step) { | |
infoPanel.step.textContent = `${currentStepIndex + 1}. ${step.info || step.blockName}`; | |
infoPanel.dimensions.textContent = step.dimension; | |
} else { // Kondisi reset | |
infoPanel.step.textContent = "Belum dimulai"; | |
infoPanel.dimensions.textContent = "-"; | |
} | |
infoPanel.inputName.textContent = currentInput.name; | |
} | |
function updateButtonStates() { | |
const simActive = !isQuizActive; // Simulation is active if quiz is NOT active | |
prevStepButton.disabled = !simActive || currentStepIndex <= -1; | |
nextStepButton.disabled = !simActive || currentStepIndex >= simulationSteps.length - 1; | |
runAutoButton.textContent = isAutoRunning ? "Hentikan Otomatis" : "Jalankan Otomatis"; | |
runAutoButton.disabled = !simActive || isAutoRunning; // Disable if quiz active OR if already auto-running | |
resetButton.disabled = !simActive; | |
inputSelect.disabled = !simActive; | |
// Only update quizButton state if it has been successfully loaded and enabled initially | |
if (quizButton && quizQuestions.length > 0) { // Check if quiz is loadable | |
quizButton.disabled = isAutoRunning || isQuizActive; // Disable quiz if auto-run active OR quiz already active | |
} else if (quizButton) { | |
quizButton.disabled = true; // Keep disabled if loading failed or no questions | |
} | |
} | |
function toggleAutoRun() { | |
if (isQuizActive) return; // Don't toggle if quiz is active | |
if (isAutoRunning) { | |
stopAutoRun(); | |
} else { | |
startAutoRun(); | |
} | |
// updateButtonStates() is called within start/stopAutoRun | |
} | |
function startAutoRun() { | |
if (isQuizActive) return; // Don't start if quiz is active | |
// ... (rest of startAutoRun logic remains largely the same) ... | |
isAutoRunning = true; | |
// runAutoButton.textContent = "Hentikan Otomatis"; // Set by updateButtonStates | |
// Disable tombol lain saat auto run (handled by updateButtonStates) | |
// prevStepButton.disabled = true; | |
// nextStepButton.disabled = true; | |
// resetButton.disabled = true; | |
// inputSelect.disabled = true; | |
// quizButton.disabled = true; // Handled by updateButtonStates | |
// Ensure the first step runs immediately if starting from reset state | |
if (currentStepIndex === -1) { | |
nextStep(); // This already calls updateButtonStates | |
} else { | |
updateButtonStates(); // Update buttons if not starting from -1 | |
} | |
autoRunInterval = setInterval(() => { | |
// Stop condition check moved inside nextStep/resetSimulation logic | |
// if (!isAutoRunning || isQuizActive) { // Stop if quiz starts or manually stopped | |
// stopAutoRun(); // Ensure interval is cleared | |
// return; | |
// } | |
if (currentStepIndex < simulationSteps.length - 1) { | |
nextStep(); // Calls updateButtonStates | |
} else { | |
// Reached the end, loop back to the beginning | |
currentStepIndex = -1; // Reset index before next step | |
clearHighlightsAndConnections(); // Clear visuals before starting over | |
updateInfoPanel(); // Update panel to initial state briefly | |
// No need to call resetSimulation() fully, just loop | |
setTimeout(nextStep, 100); // Small delay before starting the first step again | |
} | |
}, 1000); // Interval 1 detik per langkah | |
// updateButtonStates(); // Called after first nextStep or immediately if not starting from -1 | |
} | |
function stopAutoRun() { | |
if (autoRunInterval) { | |
clearInterval(autoRunInterval); | |
autoRunInterval = null; // Clear interval ID | |
} | |
isAutoRunning = false; | |
// runAutoButton.textContent = "Jalankan Otomatis"; // Set in updateButtonStates | |
// Enable tombol lain lagi (handled by updateButtonStates) | |
updateButtonStates(); // Update buttons state after stopping | |
} | |
function handleInputChange(event) { | |
if (isQuizActive) return; // Don't change input during quiz | |
const selectedValue = event.target.value; | |
currentInput = sampleInputs[selectedValue]; | |
// Perbarui definisi langkah simulasi karena dimensi awal berubah | |
defineSimulationSteps(); | |
resetSimulation(); // Reset simulasi dengan input baru (calls updateButtonStates) | |
} | |
// --- Visualisasi Feature Map Sederhana (Placeholder) --- | |
function generateSimpleFeatureMap(ctx, dimensions) { | |
const size = 128; // Ukuran canvas | |
ctx.clearRect(0, 0, size, size); | |
ctx.fillStyle = '#f0f0f0'; // Latar belakang | |
ctx.fillRect(0, 0, size, size); | |
// Parse dimensi (contoh: "128x128x64") | |
const dims = dimensions.split('x').map(Number); | |
const channels = dims[2] || 1; | |
// Gambar grid acak sederhana untuk representasi | |
const cellSize = 4; | |
for (let y = 0; y < size; y += cellSize) { | |
for (let x = 0; x < size; x += cellSize) { | |
// Warna acak berdasarkan channel (simplifikasi) | |
const grayValue = Math.floor(Math.random() * 200) + 55; // Nilai abu-abu acak | |
const blueTint = Math.min(255, Math.floor(Math.random() * channels * 0.5)); // Sedikit warna biru berdasarkan channel | |
ctx.fillStyle = `rgb(${grayValue}, ${grayValue}, ${Math.min(255, grayValue + blueTint)})`; | |
ctx.fillRect(x, y, cellSize, cellSize); | |
} | |
} | |
ctx.strokeStyle = '#ccc'; | |
ctx.lineWidth = 0.5; | |
// Optional: Draw grid lines | |
// for (let i = 0; i <= size; i += cellSize*2) { | |
// ctx.beginPath(); | |
// ctx.moveTo(i, 0); | |
// ctx.lineTo(i, size); | |
// ctx.stroke(); | |
// ctx.beginPath(); | |
// ctx.moveTo(0, i); | |
// ctx.lineTo(size, i); | |
// ctx.stroke(); | |
// } | |
} | |
// --- Visualisasi Output Segmentasi (Placeholder) --- | |
function generateSegmentationMap(ctx, dimensions) { | |
const size = 128; | |
ctx.clearRect(0, 0, size, size); | |
// Gambar pola sederhana (misal: lingkaran di tengah) | |
ctx.fillStyle = '#333'; // Background | |
ctx.fillRect(0, 0, size, size); | |
ctx.fillStyle = '#00ff00'; // Warna segmentasi (hijau) | |
ctx.beginPath(); | |
ctx.arc(size / 2, size / 2, size / 3, 0, Math.PI * 2); // Lingkaran | |
ctx.fill(); | |
ctx.fillStyle = '#555'; | |
ctx.font = '10px Arial'; | |
ctx.textAlign = 'center'; | |
ctx.fillText('Contoh Output', size/2, size - 5); | |
} | |
// --- Event Handling --- | |
function onWindowResize() { | |
camera.aspect = container.clientWidth / container.clientHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(container.clientWidth, container.clientHeight); | |
} | |
// Function to handle click - Now opens the detail modal | |
function onCanvasClick(event) { | |
if (isQuizActive) return; // Ignore clicks if quiz is active | |
// Calculate mouse position in normalized device coordinates (-1 to +1) | |
mouse.x = (event.clientX / container.clientWidth) * 2 - 1; | |
mouse.y = - (event.clientY / container.clientHeight) * 2 + 1; | |
// Update the picking ray | |
raycaster.setFromCamera(mouse, camera); | |
// Calculate objects intersecting the picking ray | |
const intersects = raycaster.intersectObjects(Object.values(networkObjects), false); // Only check network blocks | |
if (intersects.length > 0) { | |
const intersectedObject = intersects[0].object; | |
if (tooltipElement) tooltipElement.style.display = 'none'; // Hide tooltip on click | |
showModal(intersectedObject); // Call function to show detail modal | |
} else { | |
hideModal(); // Hide detail modal if clicking outside blocks (optional) | |
} | |
} | |
// Function to handle mouse move for tooltip | |
function onCanvasMouseMove(event) { | |
if (isQuizActive || modalElement.style.display === 'block') { // Hide tooltip if quiz active or detail modal shown | |
if (tooltipElement) tooltipElement.style.display = 'none'; | |
return; | |
} | |
// Calculate mouse position relative to the container | |
const rect = container.getBoundingClientRect(); | |
const canvasX = event.clientX - rect.left; | |
const canvasY = event.clientY - rect.top; | |
// Calculate mouse position in normalized device coordinates (-1 to +1) | |
mouse.x = (canvasX / container.clientWidth) * 2 - 1; | |
mouse.y = - (canvasY / container.clientHeight) * 2 + 1; | |
// Update the picking ray | |
raycaster.setFromCamera(mouse, camera); | |
// Calculate objects intersecting the picking ray | |
const intersects = raycaster.intersectObjects(Object.values(networkObjects), false); // Only check network blocks | |
if (intersects.length > 0) { | |
const intersectedObject = intersects[0].object; | |
// Update tooltip content and position | |
if (tooltipElement) { | |
tooltipElement.style.display = 'block'; | |
tooltipElement.style.left = `${event.clientX + 15}px`; // Position tooltip near cursor | |
tooltipElement.style.top = `${event.clientY + 15}px`; | |
// Keep tooltip concise - add click instruction | |
tooltipElement.textContent = `Blok: ${intersectedObject.name}\nDimensi: ${intersectedObject.userData.dimensions}\n(Klik untuk detail)`; | |
} | |
} else { | |
// Hide tooltip if not hovering over a block | |
if (tooltipElement) tooltipElement.style.display = 'none'; | |
} | |
} | |
// --- Modal Functions (Detail Modal) --- | |
function showModal(blockObject) { | |
modalTitle.textContent = `Detail Blok: ${blockObject.name}`; | |
modalBlockName.textContent = blockObject.name; | |
modalBlockDimensions.textContent = blockObject.userData.dimensions || "N/A"; | |
modalBlockDescription.textContent = blockObject.userData.info || "Tidak ada deskripsi."; | |
modalElement.style.display = 'block'; | |
modalBackdrop.style.display = 'block'; | |
if (tooltipElement) tooltipElement.style.display = 'none'; // Hide tooltip when modal opens | |
} | |
function hideModal() { | |
modalElement.style.display = 'none'; | |
modalBackdrop.style.display = 'none'; | |
} | |
// --- Quiz Functions --- | |
function startQuiz() { | |
console.log("startQuiz called."); | |
if (isAutoRunning || quizQuestions.length === 0 || isQuizActive) { | |
console.log("startQuiz aborted: isAutoRunning=", isAutoRunning, "quizQuestions.length=", quizQuestions.length, "isQuizActive=", isQuizActive); | |
return; | |
} | |
// Stop simulation if running | |
if (isAutoRunning) { | |
stopAutoRun(); | |
console.log("stopAutoRun called from startQuiz."); | |
} | |
isQuizActive = true; | |
currentQuizQuestionIndex = 0; | |
quizScore = 0; | |
quizAnswerSubmitted = false; // Reset submitted flag globally (will be handled per question display) | |
// --- HIDE SIMULATION VIEW --- | |
console.log("Hiding simulation container..."); | |
container.style.display = 'none'; // Hide the main canvas container | |
if (tooltipElement) { | |
tooltipElement.style.display = 'none'; // Ensure tooltip is hidden | |
} | |
// --- END HIDE SIMULATION VIEW --- | |
updateButtonStates(); // Disable simulation controls | |
updateQuizScoreDisplay(); | |
displayQuizQuestion(currentQuizQuestionIndex); // Display first question (handles button states) | |
quizResultsEl.style.display = 'none'; // Hide results area | |
quizQuestionContainer.style.display = 'block'; // Show question area | |
quizSubmitButton.onclick = handleQuizSubmit; // Ensure submit handler is attached | |
// Ensure correct buttons are visible/hidden initially | |
quizSubmitButton.style.display = 'inline-block'; | |
quizBackButton.style.display = 'inline-block'; | |
quizNextButton.style.display = 'inline-block'; | |
quizCloseButton.style.display = 'inline-block'; // Keep close button always visible | |
console.log("Showing quiz modal and backdrop..."); | |
quizModal.style.display = 'block'; | |
quizModalBackdrop.style.display = 'block'; | |
// ... logging ... | |
} | |
function endQuiz() { | |
console.log("endQuiz called."); | |
isQuizActive = false; | |
hideQuizModal(); | |
// --- SHOW SIMULATION VIEW --- | |
console.log("Showing simulation container..."); | |
container.style.display = 'block'; // Show the main canvas container again | |
// --- END SHOW SIMULATION VIEW --- | |
updateButtonStates(); // Re-enable simulation controls | |
} | |
function hideQuizModal() { | |
console.log("hideQuizModal called."); | |
quizModal.style.display = 'none'; | |
quizModalBackdrop.style.display = 'none'; | |
} | |
function displayQuizQuestion(index) { | |
// ... existing checks for question loading and index bounds ... | |
if (index >= quizQuestions.length) { | |
showQuizResults(); | |
return; | |
} | |
const q = quizQuestions[index]; | |
console.log(`Displaying question ${index + 1}:`, q.question); | |
quizQuestionEl.textContent = `Pertanyaan ${index + 1}: ${q.question}`; | |
quizOptionsEl.innerHTML = ''; // Clear previous options | |
quizFeedbackEl.textContent = ''; // Clear feedback | |
quizFeedbackEl.className = 'quiz-feedback'; // Reset feedback style | |
quizAnswerSubmitted = false; // Reset submitted flag when displaying/navigating | |
q.options.forEach((option, i) => { | |
const optionId = `q${index}_option${i}`; | |
const div = document.createElement('div'); | |
div.className = 'quiz-option'; | |
div.innerHTML = ` | |
<label for="${optionId}"> | |
<input type="checkbox" id="${optionId}" name="quizOption${index}" value="${i}"> | |
${option} | |
</label> | |
`; | |
quizOptionsEl.appendChild(div); | |
}); | |
// Update button states | |
quizSubmitButton.textContent = "Submit Jawaban"; | |
quizSubmitButton.disabled = false; // Enable submit by default when navigating | |
quizBackButton.disabled = (index === 0); // Disable back if first question | |
quizNextButton.disabled = (index >= quizQuestions.length - 1); // Disable next if last question | |
quizQuestionContainer.style.display = 'block'; // Ensure question area is visible | |
quizResultsEl.style.display = 'none'; // Ensure results area is hidden | |
} | |
function handleQuizBack() { | |
if (!isQuizActive || currentQuizQuestionIndex <= 0) return; | |
currentQuizQuestionIndex--; | |
console.log("Navigating back to question:", currentQuizQuestionIndex + 1); | |
displayQuizQuestion(currentQuizQuestionIndex); | |
} | |
function handleQuizNext() { | |
if (!isQuizActive || currentQuizQuestionIndex >= quizQuestions.length - 1) return; | |
currentQuizQuestionIndex++; | |
console.log("Navigating next to question:", currentQuizQuestionIndex + 1); | |
displayQuizQuestion(currentQuizQuestionIndex); | |
} | |
function handleQuizSubmit() { | |
if (!isQuizActive || quizAnswerSubmitted) return; // Don't submit if already submitted for this view | |
console.log("handleQuizSubmit called. currentQuestionIndex:", currentQuizQuestionIndex); | |
// Submit the current answer | |
const selectedOptions = quizOptionsEl.querySelectorAll('input[type="checkbox"]:checked'); | |
if (selectedOptions.length === 0) { | |
quizFeedbackEl.textContent = "Silakan pilih setidaknya satu jawaban."; | |
quizFeedbackEl.className = 'quiz-feedback incorrect'; | |
return; // Don't proceed if no answer selected | |
} | |
const selectedAnswers = Array.from(selectedOptions).map(input => parseInt(input.value)); | |
const currentQuestion = quizQuestions[currentQuizQuestionIndex]; | |
console.log("Submitting answer for question", currentQuizQuestionIndex + 1, "Selected:", selectedAnswers); | |
const isCorrect = checkQuizAnswer(selectedAnswers, currentQuestion.correctAnswers); | |
if (isCorrect) { | |
quizScore++; // Increment score only on the first correct submission for a question (needs better state if re-answering allowed) | |
quizFeedbackEl.textContent = "Benar!"; | |
quizFeedbackEl.className = 'quiz-feedback correct'; | |
console.log("Answer correct. Score:", quizScore); | |
} else { | |
quizFeedbackEl.textContent = `Salah. Jawaban yang benar: ${formatCorrectAnswers(currentQuestion)}`; | |
quizFeedbackEl.className = 'quiz-feedback incorrect'; | |
console.log("Answer incorrect."); | |
} | |
quizAnswerSubmitted = true; // Mark as submitted for this question view | |
updateQuizScoreDisplay(); | |
quizSubmitButton.disabled = true; // Disable submit after answering for this view | |
// quizSubmitButton.textContent = "Jawaban Terkirim"; // Optional: Change text | |
// Disable checkboxes after submitting | |
const allCheckboxes = quizOptionsEl.querySelectorAll('input[type="checkbox"]'); | |
allCheckboxes.forEach(cb => cb.disabled = true); | |
// Note: Score calculation might need adjustment if users can go back and change answers. | |
// Current simple approach: score increments only once per question index if correct on first submit shown. | |
} | |
// Missing functions - add these before showQuizResults | |
function checkQuizAnswer(selectedAnswers, correctAnswers) { | |
// Check if arrays have the same length and same values (regardless of order) | |
if (selectedAnswers.length !== correctAnswers.length) { | |
return false; | |
} | |
// Make copies to avoid modifying the originals | |
const selected = [...selectedAnswers].sort(); | |
const correct = [...correctAnswers].sort(); | |
// Compare each element | |
for (let i = 0; i < selected.length; i++) { | |
if (selected[i] !== correct[i]) { | |
return false; | |
} | |
} | |
return true; | |
} | |
function formatCorrectAnswers(question) { | |
// Format the correct answers as text for feedback | |
const correctIndices = question.correctAnswers; | |
const correctOptions = correctIndices.map(index => | |
String.fromCharCode(65 + index) + ": " + question.options[index] | |
); | |
return correctOptions.join(", "); | |
} | |
function updateQuizScoreDisplay() { | |
// Update the quiz score display element | |
if (quizScoreEl) { | |
quizScoreEl.textContent = `Skor: ${quizScore}/${quizQuestions.length}`; | |
} | |
} | |
function showQuizResults() { | |
console.log("showQuizResults called."); | |
// ... existing checks ... | |
quizQuestionContainer.style.display = 'none'; // Hide question area | |
quizResultsEl.style.display = 'block'; // Show results area | |
quizFinalScoreEl.textContent = `Skor Akhir Anda: ${quizScore} dari ${quizQuestions.length} soal.`; | |
// Hide navigation/submit buttons, keep only Close | |
quizSubmitButton.style.display = 'none'; | |
quizBackButton.style.display = 'none'; | |
quizNextButton.style.display = 'none'; | |
quizCloseButton.style.display = 'inline-block'; // Ensure close is visible | |
// No need to change submit button's onclick here, just hide it. | |
console.log("Quiz results shown. Navigation/Submit hidden."); | |
} | |
// --- Animation Loop --- | |
function animate() { | |
requestAnimationFrame(animate); | |
// Only update OrbitControls if the simulation is visible and quiz is not active | |
if (!isQuizActive && container.style.display !== 'none') { | |
controls.update(); | |
} | |
renderer.render(scene, camera); | |
} | |
// --- Mulai Aplikasi --- | |
init(); | |
</script> | |
</body> | |
</html> | |