File size: 34,425 Bytes
e07e0a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
<!DOCTYPE html>
<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>