Spaces:
Running
Running
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Simulasi 3D Penyimpanan Arsip Blockchain</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| /* CSS Dasar */ | |
| body { margin: 0; overflow: hidden; font-family: 'Inter', sans-serif; background-color: #1f2937; } | |
| #info { /* Info box di tengah atas */ | |
| position: absolute; top: 10px; width: 90%; max-width: 600px; left: 50%; | |
| transform: translateX(-50%); text-align: center; z-index: 100; color: #e5e7eb; | |
| padding: 12px; background-color: rgba(55, 65, 81, 0.85); border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 0.9rem; | |
| min-height: 4em; display: flex; align-items: center; justify-content: center; | |
| pointer-events: none; /* Agar tidak mengganggu hover di bawahnya */ | |
| } | |
| #buttonContainer { /* Kontainer untuk tombol Start/Reset di bawah */ | |
| position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); | |
| z-index: 100; display: flex; gap: 1rem; | |
| } | |
| .simButton { /* Style umum untuk tombol simulasi */ | |
| padding: 10px 20px; border-radius: 8px; font-weight: 600; color: white; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: background-color 0.3s ease; cursor: pointer; | |
| text-align: center; flex-grow: 1; | |
| } | |
| .simButton:disabled { background-color: #6b7280; cursor: not-allowed; opacity: 0.7; } | |
| #startButton { background-color: #3b82f6; } #startButton:hover:not(:disabled) { background-color: #2563eb; } | |
| #resetButton { background-color: #ef4444; } #resetButton:hover:not(:disabled) { background-color: #dc2626; } | |
| #logHistoryPanel { /* Panel Log Histori di kiri */ | |
| position: absolute; top: 10px; left: 10px; width: 256px; | |
| max-height: calc(100vh - 40px); /* Sesuaikan tinggi maks */ | |
| z-index: 50; display: flex; flex-direction: column; | |
| background-color: rgba(31, 41, 55, 0.85); /* Samakan dengan info box */ | |
| padding: 0.75rem; border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| pointer-events: auto; /* Pastikan bisa di-scroll */ | |
| } | |
| #logHistoryContent { flex-grow: 1; overflow-y: auto; padding-right: 8px; margin-top: 8px; } | |
| #logHistoryContent::-webkit-scrollbar { width: 6px; } #logHistoryContent::-webkit-scrollbar-track { background: rgba(75, 85, 99, 0.5); border-radius: 3px; } #logHistoryContent::-webkit-scrollbar-thumb { background: #9ca3af; border-radius: 3px; } #logHistoryContent::-webkit-scrollbar-thumb:hover { background: #6b7280; } | |
| .logEntry { font-size: 0.8rem; padding-bottom: 4px; border-bottom: 1px solid rgba(107, 114, 128, 0.3); margin-bottom: 4px; color: #d1d5db;} | |
| .logTimestamp { color: #9ca3af; margin-right: 5px; } .logMessage {} | |
| /* Panel Info Blockchain di Kanan */ | |
| #blockchainInfoPanel { | |
| position: absolute; top: 10px; right: 10px; width: 256px; /* Samakan lebar dgn log */ | |
| max-height: calc(100vh - 40px); | |
| z-index: 50; display: flex; flex-direction: column; | |
| background-color: rgba(31, 41, 55, 0.85); | |
| padding: 0.75rem; border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| color: #d1d5db; /* Warna teks default */ | |
| pointer-events: auto; /* Pastikan bisa di-scroll */ | |
| } | |
| #blockchainInfoPanel h3 { /* Style judul panel */ | |
| font-weight: 600; /* Bold */ font-size: 1rem; /* Base */ margin-bottom: 0.5rem; | |
| border-bottom: 1px solid #4b5563; /* Gray 600 */ padding-bottom: 0.25rem; | |
| color: #e5e7eb; /* Gray 200 */ flex-shrink: 0; | |
| } | |
| #blockchainInfoContent { /* Konten yg bisa scroll */ | |
| flex-grow: 1; overflow-y: auto; space-y-2; /* Jarak antar paragraf */ | |
| padding-right: 8px; margin-top: 8px; font-size: 0.8rem; /* Ukuran font konten */ | |
| } | |
| #blockchainInfoContent p strong { color: #9ca3af; } /* Warna label */ | |
| #blockchainInfoContent span { color: #e5e7eb; word-break: break-all; } /* Warna nilai & wrap */ | |
| /* Scrollbar untuk panel kanan */ | |
| #blockchainInfoContent::-webkit-scrollbar { width: 6px; } | |
| #blockchainInfoContent::-webkit-scrollbar-track { background: rgba(75, 85, 99, 0.5); border-radius: 3px; } | |
| #blockchainInfoContent::-webkit-scrollbar-thumb { background: #9ca3af; border-radius: 3px; } | |
| #blockchainInfoContent::-webkit-scrollbar-thumb:hover { background: #6b7280; } | |
| canvas { display: block; /* Hapus cursor: pointer; karena hover ditangani JS */ } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="mainUI"> | |
| <div id="info">Arahkan kursor ke komponen (kotak berwarna) untuk info, atau mulai simulasi penyimpanan.</div> | |
| <div id="buttonContainer"> | |
| <button id="startButton" class="simButton">Mulai Simulasi</button> | |
| <button id="resetButton" class="simButton">Reset Simulasi</button> | |
| </div> | |
| <div id="logHistoryPanel"> | |
| <h3 class="font-bold text-base mb-1 border-b border-gray-600 pb-1 text-gray-100 flex-shrink-0">Log Histori</h3> | |
| <div id="logHistoryContent"></div> | |
| </div> | |
| <div id="blockchainInfoPanel" class="hidden"> | |
| <h3>Detail Blok Terbaru</h3> | |
| <div id="blockchainInfoContent"> | |
| <p><strong>Blok #:</strong> <span id="bcPanel-blockNum">-</span></p> | |
| <p><strong>Timestamp:</strong> <span id="bcPanel-timestamp">-</span></p> | |
| <p><strong>Tx ID (Sim):</strong> <span id="bcPanel-txId">-</span></p> | |
| <p><strong>Metadata (Sim):</strong> <span id="bcPanel-metadata">-</span></p> | |
| <p><strong>File Hash (Sim):</strong> <span id="bcPanel-fileHash">-</span></p> | |
| <p><strong>IPFS CID (Sim):</strong> <span id="bcPanel-ipfsCid">-</span></p> | |
| </div> | |
| </div> | |
| </div> <div id="container"></div> | |
| <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> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.js"></script> | |
| <script type="module"> | |
| // === Variabel Global === | |
| let scene, camera, renderer, controls; | |
| let clientNode, appServerNode, ipfsNode; | |
| let blockchainNodes = []; | |
| const mainUI = document.getElementById('mainUI'); | |
| const infoElement = document.getElementById('info'); | |
| const startButton = document.getElementById('startButton'); | |
| const resetButton = document.getElementById('resetButton'); | |
| const container = document.getElementById('container'); | |
| const logHistoryContent = document.getElementById('logHistoryContent'); | |
| const blockchainInfoPanel = document.getElementById('blockchainInfoPanel'); | |
| const bcPanelBlockNum = document.getElementById('bcPanel-blockNum'); | |
| const bcPanelTimestamp = document.getElementById('bcPanel-timestamp'); | |
| const bcPanelTxId = document.getElementById('bcPanel-txId'); | |
| const bcPanelMetadata = document.getElementById('bcPanel-metadata'); | |
| const bcPanelFileHash = document.getElementById('bcPanel-fileHash'); | |
| const bcPanelIpfsCid = document.getElementById('bcPanel-ipfsCid'); | |
| // Variabel untuk Interaksi Hover | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| let clickableObjects = []; // Tetap gunakan nama ini, isinya node yg bisa di-hover | |
| let currentlyHoveredObject = null; // Lacak objek yg sedang di-hover | |
| const defaultInfoText = "Arahkan kursor ke komponen (kotak berwarna) untuk info, atau mulai simulasi penyimpanan."; | |
| const blockGeometry = new THREE.BoxGeometry(0.6, 0.6, 0.6); | |
| const maxVisibleBlocks = 4; | |
| const blockSpacing = 0.7; | |
| const nodePositions = { client: new THREE.Vector3(-15, 0, 0), appServer: new THREE.Vector3(0, 5, 0), ipfs: new THREE.Vector3(15, 0, 0), blockchainCenter: new THREE.Vector3(0, -10, 0), blockchainRadius: 10, numBlockchainNodes: 5 }; | |
| const dataColors = { file: 0x00ff00, cid: 0x00ffff, transaction: 0xff00ff, newBlock: 0x3b82f6, request: 0xffaa00, error: 0xff0000 }; | |
| let archiveCount = 0; | |
| // === Inisialisasi === | |
| function init() { | |
| scene = new THREE.Scene(); scene.background = new THREE.Color(0x1f2937); | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = 35; camera.position.y = 10; | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| container.appendChild(renderer.domElement); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); | |
| directionalLight.position.set(5, 15, 10); scene.add(directionalLight); | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; controls.dampingFactor = 0.05; | |
| controls.screenSpacePanning = false; controls.minDistance = 5; controls.maxDistance = 100; | |
| controls.target.set(0, 0, 0); | |
| createNodesAndSeparators(); | |
| resetLogHistory(); | |
| archiveCount = 0; | |
| if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden'); | |
| window.addEventListener('resize', onWindowResize, false); | |
| startButton.addEventListener('click', startSimulation); | |
| resetButton.addEventListener('click', resetSimulation); | |
| // *** Ganti listener mousedown dengan mousemove *** | |
| // renderer.domElement.removeEventListener('mousedown', onDocumentMouseDown, false); // Hapus jika ada | |
| renderer.domElement.addEventListener('mousemove', onDocumentMouseMove, false); | |
| animate(); | |
| } | |
| // === Pembuatan Objek 3D === | |
| function createNodesAndSeparators() { | |
| const planeYOffset = -1.6; const titleYOffset = 1; const appZonePlaneGeo = new THREE.PlaneGeometry(25, 15); const appZoneMaterial = new THREE.MeshStandardMaterial({ color: 0x2a3a59, side: THREE.DoubleSide, roughness: 1.0 }); const appZonePlane = new THREE.Mesh(appZonePlaneGeo, appZoneMaterial); appZonePlane.rotation.x = -Math.PI / 2; const appZoneCenterX = (nodePositions.client.x + nodePositions.appServer.x) / 2; const appZoneCenterZ = (nodePositions.client.z + nodePositions.appServer.z) / 2; appZonePlane.position.set(appZoneCenterX, planeYOffset + 2, appZoneCenterZ); scene.add(appZonePlane); addZoneTitle("Zona Aplikasi", appZoneCenterX, appZonePlane.position.y + titleYOffset, appZoneCenterZ + 9); const bcZonePlaneGeo = new THREE.PlaneGeometry(nodePositions.blockchainRadius * 2.5, nodePositions.blockchainRadius * 2.5); const bcZoneMaterial = new THREE.MeshStandardMaterial({ color: 0x444444, side: THREE.DoubleSide, roughness: 1.0 }); const bcZonePlane = new THREE.Mesh(bcZonePlaneGeo, bcZoneMaterial); bcZonePlane.rotation.x = -Math.PI / 2; bcZonePlane.position.copy(nodePositions.blockchainCenter).y = nodePositions.blockchainCenter.y + planeYOffset; scene.add(bcZonePlane); addZoneTitle("Jaringan Blockchain", nodePositions.blockchainCenter.x, bcZonePlane.position.y + titleYOffset, nodePositions.blockchainCenter.z + nodePositions.blockchainRadius + 4); const ipfsZonePlaneGeo = new THREE.PlaneGeometry(10, 10); const ipfsZoneMaterial = new THREE.MeshStandardMaterial({ color: 0x4a235a, side: THREE.DoubleSide, roughness: 1.0 }); const ipfsZonePlane = new THREE.Mesh(ipfsZonePlaneGeo, ipfsZoneMaterial); ipfsZonePlane.rotation.x = -Math.PI / 2; ipfsZonePlane.position.copy(nodePositions.ipfs).y = nodePositions.ipfs.y + planeYOffset; scene.add(ipfsZonePlane); addZoneTitle("Penyimpanan Off-Chain", nodePositions.ipfs.x, ipfsZonePlane.position.y + titleYOffset, nodePositions.ipfs.z + 6); | |
| clickableObjects = []; | |
| // *** Tambahkan emissive: 0x000000 pada material node *** | |
| const nodeGeometry = new THREE.BoxGeometry(3, 3, 3); | |
| const clientMaterial = new THREE.MeshStandardMaterial({ color: 0x4287f5, emissive: 0x000000 }); | |
| const appServerMaterial = new THREE.MeshStandardMaterial({ color: 0xf5a642, emissive: 0x000000 }); | |
| const ipfsMaterial = new THREE.MeshStandardMaterial({ color: 0x9e42f5, emissive: 0x000000 }); | |
| const blockchainMaterialBase = new THREE.MeshStandardMaterial({ color: 0xcccccc, transparent: true, opacity: 0.9, emissive: 0x000000 }); | |
| clientNode = new THREE.Mesh(nodeGeometry, clientMaterial); clientNode.position.copy(nodePositions.client); clientNode.userData = { description: "Perangkat Pengguna (Client): Titik interaksi awal pengguna, tempat arsip diunggah atau diakses.", objectType: "node" }; scene.add(clientNode); addLabel(clientNode, "Pengguna"); clickableObjects.push(clientNode); | |
| appServerNode = new THREE.Mesh(nodeGeometry, appServerMaterial); appServerNode.position.copy(nodePositions.appServer); appServerNode.userData = { description: "Server Aplikasi: Memproses permintaan, mengelola metadata & hash, berinteraksi dengan IPFS dan Blockchain.", objectType: "node" }; scene.add(appServerNode); addLabel(appServerNode, "Server App"); clickableObjects.push(appServerNode); | |
| ipfsNode = new THREE.Mesh(nodeGeometry, ipfsMaterial); ipfsNode.position.copy(nodePositions.ipfs); ipfsNode.userData = { description: "IPFS (InterPlanetary File System): Sistem penyimpanan terdistribusi untuk menyimpan file arsip besar secara off-chain.", objectType: "node" }; scene.add(ipfsNode); addLabel(ipfsNode, "IPFS"); clickableObjects.push(ipfsNode); | |
| const angleStep = (Math.PI * 2) / nodePositions.numBlockchainNodes; | |
| for (let i = 0; i < nodePositions.numBlockchainNodes; i++) { | |
| const angle = i * angleStep; const x = nodePositions.blockchainCenter.x + nodePositions.blockchainRadius * Math.cos(angle); const z = nodePositions.blockchainCenter.z + nodePositions.blockchainRadius * Math.sin(angle); | |
| // *** Clone material base agar setiap node punya material instance sendiri *** | |
| const nodeMaterialInstance = blockchainMaterialBase.clone(); | |
| const node = new THREE.Mesh(nodeGeometry.clone(), nodeMaterialInstance); | |
| node.position.set(x, nodePositions.blockchainCenter.y, z); node.userData = { chainBlocks: [], description: `Node Blockchain ${i + 1}: Menyimpan salinan ledger, memvalidasi transaksi, dan mencapai konsensus.`, objectType: "node" }; scene.add(node); blockchainNodes.push(node); addLabel(node, `Node BC ${i + 1}`); clickableObjects.push(node); | |
| } | |
| } | |
| // --- Fungsi Label --- | |
| function createTextSprite(message, parameters) { /* ... kode sama ... */ | |
| const fontface = parameters.fontface || 'Arial'; const fontsize = parameters.fontsize || 18; const borderThickness = parameters.borderThickness || 4; const borderColor = parameters.borderColor || { r:0, g:0, b:0, a:1.0 }; const backgroundColor = parameters.backgroundColor || { r:255, g:255, b:255, a:1.0 }; const textColor = parameters.textColor || { r:0, g:0, b:0, a:1.0 }; const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context.font = `Bold ${fontsize}px ${fontface}`; const metrics = context.measureText(message); const textWidth = metrics.width; context.fillStyle = `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, ${backgroundColor.a})`; context.strokeStyle = `rgba(${borderColor.r}, ${borderColor.g}, ${borderColor.b}, ${borderColor.a})`; context.lineWidth = borderThickness; roundRect(context, borderThickness/2, borderThickness/2, textWidth + borderThickness, fontsize * 1.4 + borderThickness, 6); context.fillStyle = `rgba(${textColor.r}, ${textColor.g}, ${textColor.b}, 1.0)`; context.fillText( message, borderThickness, fontsize + borderThickness); const texture = new THREE.Texture(canvas); texture.needsUpdate = true; const spriteMaterial = new THREE.SpriteMaterial({ map: texture }); const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(0.5 * fontsize, 0.25 * fontsize, 0.75 * fontsize); return sprite; | |
| } | |
| function roundRect(ctx, x, y, w, h, r) { /* ... kode sama ... */ | |
| ctx.beginPath(); ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w, y, x+w, y+r); ctx.lineTo(x+w, y+h-r); ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h); ctx.lineTo(x+r, y+h); ctx.quadraticCurveTo(x, y+h, x, y+h-r); ctx.lineTo(x, y+r); ctx.quadraticCurveTo(x, y, x+r, y); ctx.closePath(); ctx.fill(); ctx.stroke(); | |
| } | |
| function addLabel(node, text) { /* ... kode sama ... */ | |
| const sprite = createTextSprite(text, { fontsize: 20, fontface: 'Arial', textColor: { r:255, g:255, b:255, a:1.0 }, backgroundColor: { r:0, g:0, b:0, a:0.6 }, borderColor: { r:255, g:255, b:255, a:0.8 } }); sprite.position.set(node.position.x, node.position.y + 2.0, node.position.z); scene.add(sprite); | |
| } | |
| function addZoneTitle(text, x, y, z) { /* ... kode sama ... */ | |
| const titleSprite = createTextSprite(text, { fontsize: 28, fontface: 'Arial', textColor: { r:210, g:210, b:210, a:1.0 }, backgroundColor: { r:0, g:0, b:0, a:0.0 }, borderColor: { r:0, g:0, b:0, a:0.0 } }); titleSprite.position.set(x, y + 0.5, z); scene.add(titleSprite); | |
| } | |
| // --- Akhir Fungsi Label --- | |
| // === Logika Animasi (tidak berubah) === | |
| function animateFlowLine(startNode, endNode, duration, color) { /* ... kode sama, return Promise ... */ | |
| return new Promise(resolve => { const positions = new Float32Array(2 * 3); positions[0] = startNode.position.x; positions[1] = startNode.position.y; positions[2] = startNode.position.z; positions[3] = startNode.position.x; positions[4] = startNode.position.y; positions[5] = startNode.position.z; const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const material = new THREE.LineBasicMaterial({ color: color, linewidth: 3, transparent: true, opacity: 1.0 }); const line = new THREE.Line(geometry, material); scene.add(line); const targetPosition = { x: startNode.position.x, y: startNode.position.y, z: startNode.position.z }; new TWEEN.Tween(targetPosition) .to({ x: endNode.position.x, y: endNode.position.y, z: endNode.position.z }, duration) .easing(TWEEN.Easing.Linear.None) .onUpdate(() => { const currentPositions = line.geometry.attributes.position.array; currentPositions[3] = targetPosition.x; currentPositions[4] = targetPosition.y; currentPositions[5] = targetPosition.z; line.geometry.attributes.position.needsUpdate = true; }) .onComplete(() => { new TWEEN.Tween(line.material) .to({ opacity: 0 }, 200) .easing(TWEEN.Easing.Linear.None) .onComplete(() => { scene.remove(line); resolve(); }) .start(); }) .start(); }); | |
| } | |
| function flashNode(node, duration = 500, color = 0xffffff) { /* ... kode sama ... */ | |
| if (!node.material || !node.material.color) return; const originalColor = node.material.color.getHex(); new TWEEN.Tween(node.material.color) .to({ r: new THREE.Color(color).r, g: new THREE.Color(color).g, b: new THREE.Color(color).b }, duration / 2) .easing(TWEEN.Easing.Quadratic.Out) .yoyo(true).repeat(1) .onComplete(() => { node.material.color.setHex(originalColor); }) .start(); | |
| } | |
| function addBlockToNodeChain(node, blockColor) { /* ... kode sama, return Promise ... */ | |
| return new Promise(resolve => { if (!node || !node.geometry || !node.userData || !Array.isArray(node.userData.chainBlocks)) { console.error("Invalid node or node data for addBlockToNodeChain:", node); resolve(); return; } if (!node.geometry.boundingBox) { node.geometry.computeBoundingBox(); } if (!blockGeometry.boundingBox) { blockGeometry.computeBoundingBox(); } if (!node.geometry.boundingBox || !blockGeometry.boundingBox) { console.error("Failed to compute bounding box for node or block geometry."); resolve(); return; } const nodeSize = new THREE.Vector3(); node.geometry.boundingBox.getSize(nodeSize); const nodeHeight = nodeSize.y; const blockSize = new THREE.Vector3(); blockGeometry.boundingBox.getSize(blockSize); const blockHeight = blockSize.y; const newBlockMaterial = new THREE.MeshStandardMaterial({ color: blockColor }); const newBlock = new THREE.Mesh(blockGeometry.clone(), newBlockMaterial); const chain = node.userData.chainBlocks; const baseY = node.position.y - (nodeHeight / 2) + (blockHeight / 2); const targetY = baseY + chain.length * blockSpacing; newBlock.position.set(node.position.x, targetY + 3, node.position.z); scene.add(newBlock); chain.push(newBlock); new TWEEN.Tween(newBlock.position) .to({ y: targetY }, 800) .easing(TWEEN.Easing.Bounce.Out) .onComplete(() => { let repositionPromises = []; if (chain.length > maxVisibleBlocks) { const oldestBlock = chain.shift(); if (oldestBlock) { scene.remove(oldestBlock); } chain.forEach((currentBlock, i) => { const currentBaseY = node.position.y - (nodeHeight / 2) + (blockHeight / 2); const newTargetY = currentBaseY + i * blockSpacing; const p = new Promise(res => { new TWEEN.Tween(currentBlock.position) .to({ y: newTargetY }, 300) .easing(TWEEN.Easing.Quadratic.Out) .onComplete(res) .start(); }); repositionPromises.push(p); }); } Promise.all(repositionPromises).then(resolve); }) .start(); }); | |
| } | |
| // --- Akhir Logika Animasi --- | |
| // === Logika Log Histori (tidak berubah) === | |
| function addLogEntry(message) { /* ... kode sama ... */ | |
| if (!logHistoryContent) return; const timestamp = new Date().toLocaleTimeString('id-ID', { hour12: false }); const logElement = document.createElement('p'); logElement.classList.add('logEntry'); logElement.innerHTML = `<span class="logTimestamp">[${timestamp}]</span> <span class="logMessage">${message}</span>`; logHistoryContent.prepend(logElement); const maxLogEntries = 50; while (logHistoryContent.children.length > maxLogEntries) { logHistoryContent.removeChild(logHistoryContent.lastChild); } | |
| } | |
| function resetLogHistory() { /* ... kode sama ... */ | |
| if (logHistoryContent) { logHistoryContent.innerHTML = ''; addLogEntry("Log histori dimulai."); } | |
| } | |
| // --- Akhir Logika Log Histori --- | |
| // === Fungsi Generate Mock Data Blockchain === | |
| function generateMockData() { /* ... kode sama ... */ | |
| const txId = '0x' + [...Array(16)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); const fileHash = 'sha256-' + [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); const ipfsCid = 'Qm' + [...Array(44)].map(() => (Math.random() < 0.5 ? String.fromCharCode(Math.floor(Math.random() * 26) + 97) : Math.floor(Math.random() * 10))).join(''); return { txId, fileHash, ipfsCid }; | |
| } | |
| // === Fungsi Update Panel Info Blockchain === | |
| function updateBlockchainInfoPanel(blockNumber, mockData) { /* ... kode sama ... */ | |
| if (!bcPanelBlockNum || !bcPanelTimestamp || !bcPanelTxId || !bcPanelMetadata || !bcPanelFileHash || !bcPanelIpfsCid) { console.error("One or more blockchain info panel elements not found!"); return; } console.log(`Updating blockchain info panel for Block #${blockNumber} with data:`, mockData); bcPanelBlockNum.innerText = blockNumber; bcPanelTimestamp.innerText = new Date().toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'medium'}); bcPanelTxId.innerText = mockData.txId; bcPanelMetadata.innerText = `Data simulasi untuk arsip di blok #${blockNumber}.`; bcPanelFileHash.innerText = mockData.fileHash; bcPanelIpfsCid.innerText = mockData.ipfsCid; | |
| } | |
| // === Fungsi Increment Archive Count === | |
| function incrementArchiveCount() { /* ... kode sama ... */ | |
| archiveCount++; console.log("Archive count incremented to:", archiveCount); | |
| } | |
| // === Fungsi Reset State Visual === | |
| function resetVisualState() { | |
| blockchainNodes.forEach(node => { | |
| if(node.material.color) node.material.color.setHex(0xcccccc); | |
| // *** Reset warna emissive juga *** | |
| if(node.material.emissive) node.material.emissive.setHex(0x000000); | |
| if(node.userData && node.userData.chainBlocks){ node.userData.chainBlocks.forEach(block => scene.remove(block)); node.userData.chainBlocks = []; } | |
| }); | |
| // Reset node lain juga | |
| if(clientNode && clientNode.material.emissive) clientNode.material.emissive.setHex(0x000000); | |
| if(appServerNode && appServerNode.material.emissive) appServerNode.material.emissive.setHex(0x000000); | |
| if(ipfsNode && ipfsNode.material.emissive) ipfsNode.material.emissive.setHex(0x000000); | |
| const linesToRemove = []; scene.traverse((object) => { if (object.isLine) { linesToRemove.push(object); } }); linesToRemove.forEach(line => scene.remove(line)); | |
| infoElement.innerText = defaultInfoText; // Gunakan teks default | |
| resetLogHistory(); | |
| if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden'); | |
| currentlyHoveredObject = null; // Reset objek yang di-hover | |
| } | |
| // === Fungsi Reset Simulasi === | |
| function resetSimulation() { /* ... kode sama ... */ | |
| console.log("Resetting simulation..."); TWEEN.removeAll(); | |
| resetVisualState(); | |
| archiveCount = 0; | |
| console.log("Archive count reset to:", archiveCount); | |
| startButton.disabled = false; resetButton.disabled = false; | |
| addLogEntry("Simulasi direset."); | |
| } | |
| // === Alur Simulasi Penyimpanan === | |
| async function startSimulation() { | |
| startButton.disabled = true; resetButton.disabled = true; | |
| if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden'); | |
| resetVisualState(); | |
| updateInfo("Memulai simulasi penyimpanan..."); | |
| addLogEntry("Memulai simulasi penyimpanan..."); | |
| try { // Alur utama tidak berubah | |
| await delay(500); | |
| updateInfo("1. Pengguna memulai proses upload arsip..."); addLogEntry("Pengguna memulai upload arsip."); | |
| await delay(1500); | |
| updateInfo("2. Mengirim file arsip ke Server Aplikasi..."); addLogEntry("Mengirim file ke Server Aplikasi."); | |
| await animateFlowLine(clientNode, appServerNode, 2000, dataColors.file); | |
| flashNode(appServerNode); | |
| updateInfo("3. Server memproses: Ekstrak metadata, hitung hash..."); addLogEntry("Server memproses file (metadata, hash)."); | |
| await delay(1500); | |
| updateInfo("4. Server mengirim file ke IPFS..."); addLogEntry("Server mengirim file ke IPFS."); | |
| await animateFlowLine(appServerNode, ipfsNode, 2000, dataColors.file); | |
| flashNode(ipfsNode); | |
| updateInfo("5. IPFS menyimpan file..."); addLogEntry("IPFS menyimpan file."); | |
| await delay(1000); | |
| updateInfo("6. Server menerima CID dari IPFS..."); addLogEntry("Server menerima CID dari IPFS."); | |
| await animateFlowLine(ipfsNode, appServerNode, 1000, dataColors.cid); | |
| flashNode(appServerNode); | |
| updateInfo("7. Server membuat transaksi..."); addLogEntry("Server membuat transaksi blockchain."); | |
| await delay(1500); | |
| const targetBCNode = blockchainNodes[0]; | |
| updateInfo("8. Mengirim transaksi ke Node BC 1..."); addLogEntry("Mengirim transaksi ke Node BC 1."); | |
| await animateFlowLine(appServerNode, targetBCNode, 2000, dataColors.transaction); | |
| flashNode(targetBCNode, 500, dataColors.transaction); | |
| updateInfo("9. Node 1 menyebarkan transaksi..."); addLogEntry("Node BC 1 melakukan broadcast transaksi."); | |
| await delay(1000); | |
| const broadcastPromises = blockchainNodes.slice(1).map(node => | |
| animateFlowLine(targetBCNode, node, 1500, dataColors.transaction).then(() => { | |
| flashNode(node, 500, dataColors.transaction); | |
| }) | |
| ); | |
| await Promise.all(broadcastPromises); | |
| addLogEntry("Broadcast transaksi selesai."); await delay(500); | |
| updateInfo("10. Node melakukan proses konsensus..."); addLogEntry("Node BC memulai proses konsensus."); | |
| const consensusPromises = blockchainNodes.map(node => { | |
| return new Promise(resolve => { flashNode(node, 1000, 0x00ff00); setTimeout(resolve, 1100); }); | |
| }); | |
| await Promise.all(consensusPromises); | |
| addLogEntry("Konsensus tercapai."); | |
| updateInfo("11. Transaksi dicatat, blok baru ditambahkan..."); addLogEntry("Mencatat transaksi & menambahkan blok baru."); | |
| const blockAddingPromises = blockchainNodes.map(node => addBlockToNodeChain(node, dataColors.newBlock)); | |
| console.log("Waiting for all blocks to be added..."); | |
| await Promise.all(blockAddingPromises); | |
| console.log("All blocks added visually. Proceeding to show panel."); | |
| addLogEntry("Blok baru ditambahkan ke semua node."); await delay(1000); | |
| // --- Sukses --- | |
| updateInfo("12. Sukses! Arsip tersimpan aman, data tercatat di blockchain."); | |
| addLogEntry("Penyimpanan arsip berhasil."); | |
| incrementArchiveCount(); | |
| console.log("Generating mock data and updating panel..."); | |
| const mockData = generateMockData(); | |
| updateBlockchainInfoPanel(archiveCount, mockData); | |
| console.log("Attempting to show blockchain info panel. Classes before:", blockchainInfoPanel ? blockchainInfoPanel.className : 'null'); | |
| if (blockchainInfoPanel) blockchainInfoPanel.classList.remove('hidden'); | |
| console.log("Attempting to show blockchain info panel. Classes after:", blockchainInfoPanel ? blockchainInfoPanel.className : 'null'); | |
| console.log("Panel offsetHeight:", blockchainInfoPanel ? blockchainInfoPanel.offsetHeight : 'null'); | |
| startButton.disabled = false; | |
| resetButton.disabled = false; | |
| } catch (error) { | |
| console.error("Error during startSimulation:", error); | |
| updateInfo("Terjadi error selama simulasi penyimpanan."); | |
| addLogEntry(`Error: ${error.message}`); | |
| resetButton.disabled = false; | |
| } | |
| } | |
| // === Alur Simulasi Penelusuran (Dihapus) === | |
| // === Fungsi Simulasi Perubahan Format (Dihapus) === | |
| // === Fungsi Utilitas === | |
| function updateInfo(message) { console.log(message); infoElement.innerText = message; } | |
| function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } | |
| // === Fungsi Baru: Penanganan Hover Mouse === | |
| function onDocumentMouseMove(event) { | |
| event.preventDefault(); | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = - (event.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(clickableObjects); | |
| if (intersects.length > 0) { | |
| const intersectedObject = intersects[0].object; | |
| // Cek jika objek berbeda dari yg dihover sebelumnya | |
| if (currentlyHoveredObject !== intersectedObject) { | |
| // Reset objek yg dihover sebelumnya (jika ada) | |
| if (currentlyHoveredObject && currentlyHoveredObject.material.emissive) { | |
| currentlyHoveredObject.material.emissive.setHex(0x000000); | |
| } | |
| // Set objek baru yg dihover | |
| currentlyHoveredObject = intersectedObject; | |
| if (currentlyHoveredObject.material.emissive) { | |
| currentlyHoveredObject.material.emissive.setHex(0x555555); // Warna hover | |
| } | |
| // Tampilkan deskripsi | |
| if (currentlyHoveredObject.userData && currentlyHoveredObject.userData.description) { | |
| infoElement.innerText = currentlyHoveredObject.userData.description; | |
| } | |
| } | |
| // Jika sama, tidak perlu lakukan apa2 | |
| } else { | |
| // Jika tidak ada objek yg dihover | |
| if (currentlyHoveredObject) { | |
| // Reset objek yg sebelumnya dihover | |
| if (currentlyHoveredObject.material.emissive) { | |
| currentlyHoveredObject.material.emissive.setHex(0x000000); | |
| } | |
| currentlyHoveredObject = null; | |
| // Kembalikan info box ke default | |
| infoElement.innerText = defaultInfoText; | |
| } | |
| } | |
| } | |
| // === Fungsi Penanganan Klik Mouse (Dihapus) === | |
| // function onDocumentMouseDown(event) { ... } | |
| // === Loop Animasi & Penanganan Ukuran Jendela === | |
| function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } | |
| function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } | |
| // === Mulai Aplikasi === | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |