jnm-itb commited on
Commit
e07e0a7
·
1 Parent(s): ceceae5

Implement structural updates and optimizations across multiple modules

Browse files
Files changed (4) hide show
  1. blockchain.html +414 -0
  2. dt_preservasi.html +1571 -0
  3. index.html +68 -57
  4. unet.html +12 -0
blockchain.html ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Simulasi 3D Penyimpanan Arsip Blockchain</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ /* CSS Dasar */
10
+ body { margin: 0; overflow: hidden; font-family: 'Inter', sans-serif; background-color: #1f2937; }
11
+ #info { /* Info box di tengah atas */
12
+ position: absolute; top: 10px; width: 90%; max-width: 600px; left: 50%;
13
+ transform: translateX(-50%); text-align: center; z-index: 100; color: #e5e7eb;
14
+ padding: 12px; background-color: rgba(55, 65, 81, 0.85); border-radius: 8px;
15
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 0.9rem;
16
+ min-height: 4em; display: flex; align-items: center; justify-content: center;
17
+ pointer-events: none; /* Agar tidak mengganggu hover di bawahnya */
18
+ }
19
+ #buttonContainer { /* Kontainer untuk tombol Start/Reset di bawah */
20
+ position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
21
+ z-index: 100; display: flex; gap: 1rem;
22
+ }
23
+ .simButton { /* Style umum untuk tombol simulasi */
24
+ padding: 10px 20px; border-radius: 8px; font-weight: 600; color: white;
25
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: background-color 0.3s ease; cursor: pointer;
26
+ text-align: center; flex-grow: 1;
27
+ }
28
+ .simButton:disabled { background-color: #6b7280; cursor: not-allowed; opacity: 0.7; }
29
+ #startButton { background-color: #3b82f6; } #startButton:hover:not(:disabled) { background-color: #2563eb; }
30
+ #resetButton { background-color: #ef4444; } #resetButton:hover:not(:disabled) { background-color: #dc2626; }
31
+
32
+ #logHistoryPanel { /* Panel Log Histori di kiri */
33
+ position: absolute; top: 10px; left: 10px; width: 256px;
34
+ max-height: calc(100vh - 40px); /* Sesuaikan tinggi maks */
35
+ z-index: 50; display: flex; flex-direction: column;
36
+ background-color: rgba(31, 41, 55, 0.85); /* Samakan dengan info box */
37
+ padding: 0.75rem; border-radius: 8px;
38
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
39
+ pointer-events: auto; /* Pastikan bisa di-scroll */
40
+ }
41
+ #logHistoryContent { flex-grow: 1; overflow-y: auto; padding-right: 8px; margin-top: 8px; }
42
+ #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; }
43
+ .logEntry { font-size: 0.8rem; padding-bottom: 4px; border-bottom: 1px solid rgba(107, 114, 128, 0.3); margin-bottom: 4px; color: #d1d5db;}
44
+ .logTimestamp { color: #9ca3af; margin-right: 5px; } .logMessage {}
45
+
46
+ /* Panel Info Blockchain di Kanan */
47
+ #blockchainInfoPanel {
48
+ position: absolute; top: 10px; right: 10px; width: 256px; /* Samakan lebar dgn log */
49
+ max-height: calc(100vh - 40px);
50
+ z-index: 50; display: flex; flex-direction: column;
51
+ background-color: rgba(31, 41, 55, 0.85);
52
+ padding: 0.75rem; border-radius: 8px;
53
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
54
+ color: #d1d5db; /* Warna teks default */
55
+ pointer-events: auto; /* Pastikan bisa di-scroll */
56
+ }
57
+ #blockchainInfoPanel h3 { /* Style judul panel */
58
+ font-weight: 600; /* Bold */ font-size: 1rem; /* Base */ margin-bottom: 0.5rem;
59
+ border-bottom: 1px solid #4b5563; /* Gray 600 */ padding-bottom: 0.25rem;
60
+ color: #e5e7eb; /* Gray 200 */ flex-shrink: 0;
61
+ }
62
+ #blockchainInfoContent { /* Konten yg bisa scroll */
63
+ flex-grow: 1; overflow-y: auto; space-y-2; /* Jarak antar paragraf */
64
+ padding-right: 8px; margin-top: 8px; font-size: 0.8rem; /* Ukuran font konten */
65
+ }
66
+ #blockchainInfoContent p strong { color: #9ca3af; } /* Warna label */
67
+ #blockchainInfoContent span { color: #e5e7eb; word-break: break-all; } /* Warna nilai & wrap */
68
+ /* Scrollbar untuk panel kanan */
69
+ #blockchainInfoContent::-webkit-scrollbar { width: 6px; }
70
+ #blockchainInfoContent::-webkit-scrollbar-track { background: rgba(75, 85, 99, 0.5); border-radius: 3px; }
71
+ #blockchainInfoContent::-webkit-scrollbar-thumb { background: #9ca3af; border-radius: 3px; }
72
+ #blockchainInfoContent::-webkit-scrollbar-thumb:hover { background: #6b7280; }
73
+
74
+
75
+ canvas { display: block; /* Hapus cursor: pointer; karena hover ditangani JS */ }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div id="mainUI">
80
+ <div id="info">Arahkan kursor ke komponen (kotak berwarna) untuk info, atau mulai simulasi penyimpanan.</div>
81
+ <div id="buttonContainer">
82
+ <button id="startButton" class="simButton">Mulai Simulasi</button>
83
+ <button id="resetButton" class="simButton">Reset Simulasi</button>
84
+ </div>
85
+ <div id="logHistoryPanel">
86
+ <h3 class="font-bold text-base mb-1 border-b border-gray-600 pb-1 text-gray-100 flex-shrink-0">Log Histori</h3>
87
+ <div id="logHistoryContent"></div>
88
+ </div>
89
+
90
+ <div id="blockchainInfoPanel" class="hidden">
91
+ <h3>Detail Blok Terbaru</h3>
92
+ <div id="blockchainInfoContent">
93
+ <p><strong>Blok #:</strong> <span id="bcPanel-blockNum">-</span></p>
94
+ <p><strong>Timestamp:</strong> <span id="bcPanel-timestamp">-</span></p>
95
+ <p><strong>Tx ID (Sim):</strong> <span id="bcPanel-txId">-</span></p>
96
+ <p><strong>Metadata (Sim):</strong> <span id="bcPanel-metadata">-</span></p>
97
+ <p><strong>File Hash (Sim):</strong> <span id="bcPanel-fileHash">-</span></p>
98
+ <p><strong>IPFS CID (Sim):</strong> <span id="bcPanel-ipfsCid">-</span></p>
99
+ </div>
100
+ </div>
101
+
102
+ </div> <div id="container"></div>
103
+
104
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
105
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script>
106
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.js"></script>
107
+
108
+ <script type="module">
109
+ // === Variabel Global ===
110
+ let scene, camera, renderer, controls;
111
+ let clientNode, appServerNode, ipfsNode;
112
+ let blockchainNodes = [];
113
+ const mainUI = document.getElementById('mainUI');
114
+ const infoElement = document.getElementById('info');
115
+ const startButton = document.getElementById('startButton');
116
+ const resetButton = document.getElementById('resetButton');
117
+ const container = document.getElementById('container');
118
+ const logHistoryContent = document.getElementById('logHistoryContent');
119
+ const blockchainInfoPanel = document.getElementById('blockchainInfoPanel');
120
+ const bcPanelBlockNum = document.getElementById('bcPanel-blockNum');
121
+ const bcPanelTimestamp = document.getElementById('bcPanel-timestamp');
122
+ const bcPanelTxId = document.getElementById('bcPanel-txId');
123
+ const bcPanelMetadata = document.getElementById('bcPanel-metadata');
124
+ const bcPanelFileHash = document.getElementById('bcPanel-fileHash');
125
+ const bcPanelIpfsCid = document.getElementById('bcPanel-ipfsCid');
126
+
127
+ // Variabel untuk Interaksi Hover
128
+ const raycaster = new THREE.Raycaster();
129
+ const mouse = new THREE.Vector2();
130
+ let clickableObjects = []; // Tetap gunakan nama ini, isinya node yg bisa di-hover
131
+ let currentlyHoveredObject = null; // Lacak objek yg sedang di-hover
132
+ const defaultInfoText = "Arahkan kursor ke komponen (kotak berwarna) untuk info, atau mulai simulasi penyimpanan.";
133
+
134
+ const blockGeometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
135
+ const maxVisibleBlocks = 4;
136
+ const blockSpacing = 0.7;
137
+ 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 };
138
+ const dataColors = { file: 0x00ff00, cid: 0x00ffff, transaction: 0xff00ff, newBlock: 0x3b82f6, request: 0xffaa00, error: 0xff0000 };
139
+ let archiveCount = 0;
140
+
141
+
142
+ // === Inisialisasi ===
143
+ function init() {
144
+ scene = new THREE.Scene(); scene.background = new THREE.Color(0x1f2937);
145
+ camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
146
+ camera.position.z = 35; camera.position.y = 10;
147
+ renderer = new THREE.WebGLRenderer({ antialias: true });
148
+ renderer.setSize(window.innerWidth, window.innerHeight);
149
+ container.appendChild(renderer.domElement);
150
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); scene.add(ambientLight);
151
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9);
152
+ directionalLight.position.set(5, 15, 10); scene.add(directionalLight);
153
+ controls = new THREE.OrbitControls(camera, renderer.domElement);
154
+ controls.enableDamping = true; controls.dampingFactor = 0.05;
155
+ controls.screenSpacePanning = false; controls.minDistance = 5; controls.maxDistance = 100;
156
+ controls.target.set(0, 0, 0);
157
+ createNodesAndSeparators();
158
+ resetLogHistory();
159
+ archiveCount = 0;
160
+ if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden');
161
+ window.addEventListener('resize', onWindowResize, false);
162
+ startButton.addEventListener('click', startSimulation);
163
+ resetButton.addEventListener('click', resetSimulation);
164
+ // *** Ganti listener mousedown dengan mousemove ***
165
+ // renderer.domElement.removeEventListener('mousedown', onDocumentMouseDown, false); // Hapus jika ada
166
+ renderer.domElement.addEventListener('mousemove', onDocumentMouseMove, false);
167
+ animate();
168
+ }
169
+
170
+ // === Pembuatan Objek 3D ===
171
+ function createNodesAndSeparators() {
172
+ 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);
173
+ clickableObjects = [];
174
+ // *** Tambahkan emissive: 0x000000 pada material node ***
175
+ const nodeGeometry = new THREE.BoxGeometry(3, 3, 3);
176
+ const clientMaterial = new THREE.MeshStandardMaterial({ color: 0x4287f5, emissive: 0x000000 });
177
+ const appServerMaterial = new THREE.MeshStandardMaterial({ color: 0xf5a642, emissive: 0x000000 });
178
+ const ipfsMaterial = new THREE.MeshStandardMaterial({ color: 0x9e42f5, emissive: 0x000000 });
179
+ const blockchainMaterialBase = new THREE.MeshStandardMaterial({ color: 0xcccccc, transparent: true, opacity: 0.9, emissive: 0x000000 });
180
+
181
+ 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);
182
+ 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);
183
+ 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);
184
+ const angleStep = (Math.PI * 2) / nodePositions.numBlockchainNodes;
185
+ for (let i = 0; i < nodePositions.numBlockchainNodes; i++) {
186
+ const angle = i * angleStep; const x = nodePositions.blockchainCenter.x + nodePositions.blockchainRadius * Math.cos(angle); const z = nodePositions.blockchainCenter.z + nodePositions.blockchainRadius * Math.sin(angle);
187
+ // *** Clone material base agar setiap node punya material instance sendiri ***
188
+ const nodeMaterialInstance = blockchainMaterialBase.clone();
189
+ const node = new THREE.Mesh(nodeGeometry.clone(), nodeMaterialInstance);
190
+ 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);
191
+ }
192
+ }
193
+
194
+ // --- Fungsi Label ---
195
+ function createTextSprite(message, parameters) { /* ... kode sama ... */
196
+ 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;
197
+ }
198
+ function roundRect(ctx, x, y, w, h, r) { /* ... kode sama ... */
199
+ 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();
200
+ }
201
+ function addLabel(node, text) { /* ... kode sama ... */
202
+ 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);
203
+ }
204
+ function addZoneTitle(text, x, y, z) { /* ... kode sama ... */
205
+ 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);
206
+ }
207
+ // --- Akhir Fungsi Label ---
208
+
209
+
210
+ // === Logika Animasi (tidak berubah) ===
211
+ function animateFlowLine(startNode, endNode, duration, color) { /* ... kode sama, return Promise ... */
212
+ 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(); });
213
+ }
214
+ function flashNode(node, duration = 500, color = 0xffffff) { /* ... kode sama ... */
215
+ 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();
216
+ }
217
+ function addBlockToNodeChain(node, blockColor) { /* ... kode sama, return Promise ... */
218
+ 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(); });
219
+ }
220
+ // --- Akhir Logika Animasi ---
221
+
222
+
223
+ // === Logika Log Histori (tidak berubah) ===
224
+ function addLogEntry(message) { /* ... kode sama ... */
225
+ 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); }
226
+ }
227
+ function resetLogHistory() { /* ... kode sama ... */
228
+ if (logHistoryContent) { logHistoryContent.innerHTML = ''; addLogEntry("Log histori dimulai."); }
229
+ }
230
+ // --- Akhir Logika Log Histori ---
231
+
232
+ // === Fungsi Generate Mock Data Blockchain ===
233
+ function generateMockData() { /* ... kode sama ... */
234
+ 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 };
235
+ }
236
+
237
+ // === Fungsi Update Panel Info Blockchain ===
238
+ function updateBlockchainInfoPanel(blockNumber, mockData) { /* ... kode sama ... */
239
+ 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;
240
+ }
241
+
242
+ // === Fungsi Increment Archive Count ===
243
+ function incrementArchiveCount() { /* ... kode sama ... */
244
+ archiveCount++; console.log("Archive count incremented to:", archiveCount);
245
+ }
246
+
247
+
248
+ // === Fungsi Reset State Visual ===
249
+ function resetVisualState() {
250
+ blockchainNodes.forEach(node => {
251
+ if(node.material.color) node.material.color.setHex(0xcccccc);
252
+ // *** Reset warna emissive juga ***
253
+ if(node.material.emissive) node.material.emissive.setHex(0x000000);
254
+ if(node.userData && node.userData.chainBlocks){ node.userData.chainBlocks.forEach(block => scene.remove(block)); node.userData.chainBlocks = []; }
255
+ });
256
+ // Reset node lain juga
257
+ if(clientNode && clientNode.material.emissive) clientNode.material.emissive.setHex(0x000000);
258
+ if(appServerNode && appServerNode.material.emissive) appServerNode.material.emissive.setHex(0x000000);
259
+ if(ipfsNode && ipfsNode.material.emissive) ipfsNode.material.emissive.setHex(0x000000);
260
+
261
+ const linesToRemove = []; scene.traverse((object) => { if (object.isLine) { linesToRemove.push(object); } }); linesToRemove.forEach(line => scene.remove(line));
262
+ infoElement.innerText = defaultInfoText; // Gunakan teks default
263
+ resetLogHistory();
264
+ if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden');
265
+ currentlyHoveredObject = null; // Reset objek yang di-hover
266
+ }
267
+
268
+ // === Fungsi Reset Simulasi ===
269
+ function resetSimulation() { /* ... kode sama ... */
270
+ console.log("Resetting simulation..."); TWEEN.removeAll();
271
+ resetVisualState();
272
+ archiveCount = 0;
273
+ console.log("Archive count reset to:", archiveCount);
274
+ startButton.disabled = false; resetButton.disabled = false;
275
+ addLogEntry("Simulasi direset.");
276
+ }
277
+
278
+ // === Alur Simulasi Penyimpanan ===
279
+ async function startSimulation() {
280
+ startButton.disabled = true; resetButton.disabled = true;
281
+ if (blockchainInfoPanel) blockchainInfoPanel.classList.add('hidden');
282
+ resetVisualState();
283
+ updateInfo("Memulai simulasi penyimpanan...");
284
+ addLogEntry("Memulai simulasi penyimpanan...");
285
+
286
+ try { // Alur utama tidak berubah
287
+ await delay(500);
288
+ updateInfo("1. Pengguna memulai proses upload arsip..."); addLogEntry("Pengguna memulai upload arsip.");
289
+ await delay(1500);
290
+ updateInfo("2. Mengirim file arsip ke Server Aplikasi..."); addLogEntry("Mengirim file ke Server Aplikasi.");
291
+ await animateFlowLine(clientNode, appServerNode, 2000, dataColors.file);
292
+ flashNode(appServerNode);
293
+ updateInfo("3. Server memproses: Ekstrak metadata, hitung hash..."); addLogEntry("Server memproses file (metadata, hash).");
294
+ await delay(1500);
295
+ updateInfo("4. Server mengirim file ke IPFS..."); addLogEntry("Server mengirim file ke IPFS.");
296
+ await animateFlowLine(appServerNode, ipfsNode, 2000, dataColors.file);
297
+ flashNode(ipfsNode);
298
+ updateInfo("5. IPFS menyimpan file..."); addLogEntry("IPFS menyimpan file.");
299
+ await delay(1000);
300
+ updateInfo("6. Server menerima CID dari IPFS..."); addLogEntry("Server menerima CID dari IPFS.");
301
+ await animateFlowLine(ipfsNode, appServerNode, 1000, dataColors.cid);
302
+ flashNode(appServerNode);
303
+ updateInfo("7. Server membuat transaksi..."); addLogEntry("Server membuat transaksi blockchain.");
304
+ await delay(1500);
305
+ const targetBCNode = blockchainNodes[0];
306
+ updateInfo("8. Mengirim transaksi ke Node BC 1..."); addLogEntry("Mengirim transaksi ke Node BC 1.");
307
+ await animateFlowLine(appServerNode, targetBCNode, 2000, dataColors.transaction);
308
+ flashNode(targetBCNode, 500, dataColors.transaction);
309
+ updateInfo("9. Node 1 menyebarkan transaksi..."); addLogEntry("Node BC 1 melakukan broadcast transaksi.");
310
+ await delay(1000);
311
+ const broadcastPromises = blockchainNodes.slice(1).map(node =>
312
+ animateFlowLine(targetBCNode, node, 1500, dataColors.transaction).then(() => {
313
+ flashNode(node, 500, dataColors.transaction);
314
+ })
315
+ );
316
+ await Promise.all(broadcastPromises);
317
+ addLogEntry("Broadcast transaksi selesai."); await delay(500);
318
+ updateInfo("10. Node melakukan proses konsensus..."); addLogEntry("Node BC memulai proses konsensus.");
319
+ const consensusPromises = blockchainNodes.map(node => {
320
+ return new Promise(resolve => { flashNode(node, 1000, 0x00ff00); setTimeout(resolve, 1100); });
321
+ });
322
+ await Promise.all(consensusPromises);
323
+ addLogEntry("Konsensus tercapai.");
324
+ updateInfo("11. Transaksi dicatat, blok baru ditambahkan..."); addLogEntry("Mencatat transaksi & menambahkan blok baru.");
325
+ const blockAddingPromises = blockchainNodes.map(node => addBlockToNodeChain(node, dataColors.newBlock));
326
+ console.log("Waiting for all blocks to be added...");
327
+ await Promise.all(blockAddingPromises);
328
+ console.log("All blocks added visually. Proceeding to show panel.");
329
+ addLogEntry("Blok baru ditambahkan ke semua node."); await delay(1000);
330
+
331
+ // --- Sukses ---
332
+ updateInfo("12. Sukses! Arsip tersimpan aman, data tercatat di blockchain.");
333
+ addLogEntry("Penyimpanan arsip berhasil.");
334
+ incrementArchiveCount();
335
+ console.log("Generating mock data and updating panel...");
336
+ const mockData = generateMockData();
337
+ updateBlockchainInfoPanel(archiveCount, mockData);
338
+ console.log("Attempting to show blockchain info panel. Classes before:", blockchainInfoPanel ? blockchainInfoPanel.className : 'null');
339
+ if (blockchainInfoPanel) blockchainInfoPanel.classList.remove('hidden');
340
+ console.log("Attempting to show blockchain info panel. Classes after:", blockchainInfoPanel ? blockchainInfoPanel.className : 'null');
341
+ console.log("Panel offsetHeight:", blockchainInfoPanel ? blockchainInfoPanel.offsetHeight : 'null');
342
+ startButton.disabled = false;
343
+ resetButton.disabled = false;
344
+
345
+ } catch (error) {
346
+ console.error("Error during startSimulation:", error);
347
+ updateInfo("Terjadi error selama simulasi penyimpanan.");
348
+ addLogEntry(`Error: ${error.message}`);
349
+ resetButton.disabled = false;
350
+ }
351
+ }
352
+
353
+ // === Alur Simulasi Penelusuran (Dihapus) ===
354
+ // === Fungsi Simulasi Perubahan Format (Dihapus) ===
355
+
356
+ // === Fungsi Utilitas ===
357
+ function updateInfo(message) { console.log(message); infoElement.innerText = message; }
358
+ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
359
+
360
+ // === Fungsi Baru: Penanganan Hover Mouse ===
361
+ function onDocumentMouseMove(event) {
362
+ event.preventDefault();
363
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
364
+ mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
365
+ raycaster.setFromCamera(mouse, camera);
366
+ const intersects = raycaster.intersectObjects(clickableObjects);
367
+
368
+ if (intersects.length > 0) {
369
+ const intersectedObject = intersects[0].object;
370
+ // Cek jika objek berbeda dari yg dihover sebelumnya
371
+ if (currentlyHoveredObject !== intersectedObject) {
372
+ // Reset objek yg dihover sebelumnya (jika ada)
373
+ if (currentlyHoveredObject && currentlyHoveredObject.material.emissive) {
374
+ currentlyHoveredObject.material.emissive.setHex(0x000000);
375
+ }
376
+ // Set objek baru yg dihover
377
+ currentlyHoveredObject = intersectedObject;
378
+ if (currentlyHoveredObject.material.emissive) {
379
+ currentlyHoveredObject.material.emissive.setHex(0x555555); // Warna hover
380
+ }
381
+ // Tampilkan deskripsi
382
+ if (currentlyHoveredObject.userData && currentlyHoveredObject.userData.description) {
383
+ infoElement.innerText = currentlyHoveredObject.userData.description;
384
+ }
385
+ }
386
+ // Jika sama, tidak perlu lakukan apa2
387
+ } else {
388
+ // Jika tidak ada objek yg dihover
389
+ if (currentlyHoveredObject) {
390
+ // Reset objek yg sebelumnya dihover
391
+ if (currentlyHoveredObject.material.emissive) {
392
+ currentlyHoveredObject.material.emissive.setHex(0x000000);
393
+ }
394
+ currentlyHoveredObject = null;
395
+ // Kembalikan info box ke default
396
+ infoElement.innerText = defaultInfoText;
397
+ }
398
+ }
399
+ }
400
+
401
+ // === Fungsi Penanganan Klik Mouse (Dihapus) ===
402
+ // function onDocumentMouseDown(event) { ... }
403
+
404
+
405
+ // === Loop Animasi & Penanganan Ukuran Jendela ===
406
+ function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); }
407
+ function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }
408
+
409
+ // === Mulai Aplikasi ===
410
+ init();
411
+
412
+ </script>
413
+ </body>
414
+ </html>
dt_preservasi.html ADDED
@@ -0,0 +1,1571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Digital Twin Preservasi Arsip Statis</title> <style>
7
+ body { margin: 0; font-family: 'Inter', sans-serif; background-color: #f0f0f0; color: #333; overflow: hidden; }
8
+ canvas { display: block; width: 100%; height: 100%; }
9
+ /* Style for CSS2DRenderer overlay */
10
+ #label-container {
11
+ position: absolute;
12
+ top: 0;
13
+ left: 0;
14
+ width: 100%;
15
+ height: 100%;
16
+ pointer-events: none; /* Allow clicks to pass through to the canvas */
17
+ overflow: hidden;
18
+ }
19
+ .shelf-label {
20
+ background-color: rgba(0, 0, 0, 0.6);
21
+ color: white;
22
+ padding: 3px 8px;
23
+ border-radius: 4px;
24
+ font-size: 10px; /* Smaller font size for labels */
25
+ white-space: nowrap;
26
+ /* pointer-events: none; Already handled by container */
27
+ }
28
+ /* *** Style for Temperature Tooltip *** */
29
+ .temp-tooltip {
30
+ position: absolute; /* Needed for CSS2DRenderer positioning */
31
+ background-color: rgba(255, 255, 153, 0.8); /* Light yellow */
32
+ color: black;
33
+ padding: 4px 8px;
34
+ border-radius: 4px;
35
+ font-size: 11px;
36
+ white-space: nowrap;
37
+ border: 1px solid #ccc;
38
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
39
+ display: none; /* Hidden by default */
40
+ pointer-events: none; /* Ignore mouse events */
41
+ }
42
+
43
+ #info {
44
+ position: absolute;
45
+ top: 10px;
46
+ left: 50%;
47
+ transform: translateX(-50%);
48
+ width: auto;
49
+ max-width: 80%;
50
+ text-align: center;
51
+ z-index: 100;
52
+ display: block;
53
+ background-color: rgba(255, 255, 255, 0.85);
54
+ padding: 10px 15px;
55
+ border-radius: 8px;
56
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
57
+ font-size: 1.1em;
58
+ }
59
+ #controls-container {
60
+ position: absolute;
61
+ bottom: 15px;
62
+ left: 50%;
63
+ transform: translateX(-50%);
64
+ background-color: rgba(0, 0, 0, 0.7);
65
+ padding: 12px 20px;
66
+ border-radius: 10px;
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ gap: 12px;
70
+ align-items: center;
71
+ justify-content: center;
72
+ max-width: 95%;
73
+ z-index: 100;
74
+ }
75
+ #controls-container label,
76
+ #controls-container input,
77
+ #controls-container button {
78
+ color: white;
79
+ font-size: 0.95em;
80
+ }
81
+ #controls-container button,
82
+ #controls-container input[type="file"]::file-selector-button /* Keep style for potential re-add */
83
+ {
84
+ padding: 8px 14px;
85
+ border-radius: 6px;
86
+ border: none;
87
+ background-color: #555;
88
+ cursor: pointer;
89
+ transition: background-color 0.3s, box-shadow 0.3s;
90
+ box-shadow: 0 1px 3px rgba(0,0,0,0.3);
91
+ }
92
+ #controls-container button:hover,
93
+ #controls-container input[type="file"]::file-selector-button:hover {
94
+ background-color: #777;
95
+ box-shadow: 0 2px 5px rgba(0,0,0,0.4);
96
+ }
97
+ #controls-container button:active,
98
+ #controls-container input[type="file"]::file-selector-button:active {
99
+ background-color: #444;
100
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
101
+ }
102
+ #controls-container input[type="file"] {
103
+ background: none;
104
+ border: none;
105
+ padding: 0;
106
+ box-shadow: none;
107
+ max-width: 150px;
108
+ }
109
+ /* #instructions style removed */
110
+ #archive-list-container {
111
+ position: absolute;
112
+ top: 60px; /* Adjusted position now that instructions are gone */
113
+ left: 10px;
114
+ width: 210px;
115
+ max-height: calc(100vh - 80px - 80px); /* Adjusted height */
116
+ background-color: rgba(0, 0, 0, 0.75);
117
+ color: white;
118
+ padding: 10px;
119
+ border-radius: 8px;
120
+ font-size: 0.9em;
121
+ overflow-y: auto;
122
+ z-index: 99;
123
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
124
+ }
125
+ #archive-list-container h3 {
126
+ margin-top: 0;
127
+ margin-bottom: 10px;
128
+ font-size: 1em;
129
+ border-bottom: 1px solid #666;
130
+ padding-bottom: 5px;
131
+ }
132
+ #archive-list {
133
+ list-style: none;
134
+ padding: 0;
135
+ margin: 0;
136
+ }
137
+ #archive-list li {
138
+ padding: 6px 8px;
139
+ cursor: pointer;
140
+ border-bottom: 1px solid #444;
141
+ transition: background-color 0.2s;
142
+ font-size: 0.9em;
143
+ white-space: nowrap;
144
+ overflow: hidden;
145
+ text-overflow: ellipsis;
146
+ }
147
+ #archive-list li:last-child { border-bottom: none; }
148
+ #archive-list li:hover { background-color: rgba(255, 255, 255, 0.2); }
149
+ #archive-list li.selected { background-color: rgba(100, 149, 237, 0.5); font-weight: bold; }
150
+ #message-area {
151
+ position: absolute;
152
+ bottom: 80px;
153
+ left: 50%;
154
+ transform: translateX(-50%);
155
+ background-color: rgba(34, 139, 34, 0.8);
156
+ color: white;
157
+ padding: 8px 15px;
158
+ border-radius: 5px;
159
+ z-index: 101;
160
+ display: none;
161
+ font-size: 0.9em;
162
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
163
+ text-align: center;
164
+ }
165
+ /* Style for Environment Info Panel */
166
+ #environment-info {
167
+ position: absolute;
168
+ top: 60px; /* Position below main title */
169
+ right: 10px;
170
+ background-color: rgba(0, 0, 0, 0.7);
171
+ color: white;
172
+ padding: 10px 15px;
173
+ border-radius: 8px;
174
+ font-size: 0.9em;
175
+ z-index: 99;
176
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
177
+ min-width: 200px; /* Slightly wider */
178
+ }
179
+ #environment-info h4 {
180
+ margin-top: 0;
181
+ margin-bottom: 8px;
182
+ font-size: 1em;
183
+ border-bottom: 1px solid #666;
184
+ padding-bottom: 4px;
185
+ }
186
+ #environment-info p {
187
+ margin: 5px 0; /* Adjusted margin */
188
+ font-size: 0.95em;
189
+ display: flex; /* Use flex for alignment */
190
+ justify-content: space-between; /* Space out label and value */
191
+ }
192
+ #environment-info span {
193
+ font-weight: bold;
194
+ text-align: right; /* Align value to the right */
195
+ margin-left: 10px; /* Add space between label and value */
196
+ }
197
+ /* Style for Analytics Panel */
198
+ #analytics-panel {
199
+ position: absolute;
200
+ top: 300px; /* *** ADJUSTED TOP POSITION *** Position further down */
201
+ right: 10px;
202
+ background-color: rgba(0, 0, 0, 0.75); /* Slightly darker */
203
+ color: white;
204
+ padding: 10px 15px;
205
+ border-radius: 8px;
206
+ font-size: 0.9em;
207
+ z-index: 98; /* Below env info if overlapping */
208
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
209
+ min-width: 200px;
210
+ max-height: calc(100vh - 320px - 80px); /* *** ADJUSTED MAX HEIGHT *** Limit height based on new top */
211
+ overflow-y: auto; /* Add scroll if content overflows */
212
+ }
213
+ #analytics-panel h4 {
214
+ margin-top: 0;
215
+ margin-bottom: 8px;
216
+ font-size: 1em;
217
+ border-bottom: 1px solid #666;
218
+ padding-bottom: 4px;
219
+ }
220
+ #analytics-panel h5 {
221
+ margin-top: 10px;
222
+ margin-bottom: 5px;
223
+ font-size: 0.95em;
224
+ font-weight: bold;
225
+ }
226
+ #analytics-panel ul {
227
+ list-style: none;
228
+ padding: 0;
229
+ margin: 0 0 10px 0;
230
+ font-size: 0.9em;
231
+ }
232
+ #analytics-panel li {
233
+ padding: 3px 0;
234
+ white-space: nowrap;
235
+ overflow: hidden;
236
+ text-overflow: ellipsis;
237
+ }
238
+ #analytics-panel li span { /* Style for count */
239
+ font-weight: bold;
240
+ margin-left: 8px;
241
+ float: right; /* Align count to the right */
242
+ }
243
+ #analytics-panel button { /* Style for update button */
244
+ display: block;
245
+ width: 100%;
246
+ margin-top: 10px;
247
+ padding: 5px 10px;
248
+ border-radius: 4px;
249
+ border: none;
250
+ background-color: #555;
251
+ color: white;
252
+ cursor: pointer;
253
+ transition: background-color 0.3s;
254
+ }
255
+ #analytics-panel button:hover {
256
+ background-color: #777;
257
+ }
258
+
259
+
260
+ </style>
261
+ </head>
262
+ <body>
263
+ <div id="info">Digital Twin Preservasi Arsip Statis</div> <div id="message-area">Pesan sukses/error</div>
264
+ <div id="archive-list-container">
265
+ <h3>Daftar Arsip (30)</h3>
266
+ <ul id="archive-list">
267
+ <li>Memuat data...</li>
268
+ </ul>
269
+ </div>
270
+
271
+ <div id="environment-info">
272
+ <h4>Info Lingkungan & Status</h4>
273
+ <p>Suhu Rata²: <span id="temp-value">--</span> °C</p>
274
+ <p>Kelembapan: <span id="humidity-value">--</span> %</p>
275
+ <p>Cahaya: <span id="light-value">--</span> Lux</p>
276
+ <hr style="border-color: #555; margin: 8px 0;">
277
+ <p>Total Boks: <span id="total-boxes-value">--</span></p>
278
+ <p>Utilitas: <span id="utility-value">--</span> %</p>
279
+ <p>Arsip Keluar: <span id="borrowed-value">--</span></p>
280
+ <p>Rata² Temu: <span id="avg-retrieval-time">--</span> detik</p>
281
+ </div>
282
+
283
+ <div id="analytics-panel">
284
+ <h4>Analisis Akses</h4>
285
+ <button id="update-analytics-btn">Tampilkan/Perbarui Analisis</button>
286
+ <h5>Arsip Paling Sering Diakses:</h5>
287
+ <ul id="top-accessed-list">
288
+ <li>Belum ada data.</li>
289
+ </ul>
290
+ <h5>Lorong Paling Sering Dilalui:</h5>
291
+ <ul id="top-aisles-list">
292
+ <li>Belum ada data.</li>
293
+ </ul>
294
+ </div>
295
+
296
+ <div id="controls-container">
297
+ <button id="toggle-view-button">Ganti ke Mode Jalan</button>
298
+ </div>
299
+
300
+ <div id="label-container"></div>
301
+ <div id="temp-tooltip" class="temp-tooltip"></div>
302
+
303
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
304
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script>
305
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/PointerLockControls.js"></script>
306
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/renderers/CSS2DRenderer.js"></script>
307
+
308
+ <script>
309
+ // === Basic Scene Setup ===
310
+ const scene = new THREE.Scene();
311
+ scene.background = new THREE.Color(0x87ceeb);
312
+ const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
313
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
314
+ renderer.setSize(window.innerWidth, window.innerHeight);
315
+ renderer.shadowMap.enabled = true;
316
+ document.body.appendChild(renderer.domElement);
317
+
318
+ // === CSS2D Renderer for Labels ===
319
+ const labelRenderer = new THREE.CSS2DRenderer();
320
+ labelRenderer.setSize(window.innerWidth, window.innerHeight);
321
+ labelRenderer.domElement.style.position = 'absolute';
322
+ labelRenderer.domElement.style.top = '0px';
323
+ labelRenderer.domElement.id = 'label-container';
324
+ document.body.appendChild(labelRenderer.domElement);
325
+
326
+
327
+ // === Message Area ===
328
+ const messageArea = document.getElementById('message-area');
329
+ function showMessage(text, isError = false, duration = 5000) { /* ... (showMessage function remains the same) ... */
330
+ messageArea.textContent = text;
331
+ messageArea.style.backgroundColor = isError ? 'rgba(220, 20, 60, 0.8)' : 'rgba(34, 139, 34, 0.8)';
332
+ messageArea.style.display = 'block';
333
+ setTimeout(() => {
334
+ messageArea.style.display = 'none';
335
+ }, duration);
336
+ }
337
+
338
+ // === Lighting ===
339
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
340
+ scene.add(ambientLight);
341
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
342
+ directionalLight.position.set(10, 20, 5);
343
+ directionalLight.castShadow = true;
344
+ scene.add(directionalLight);
345
+ directionalLight.shadow.mapSize.width = 1024;
346
+ directionalLight.shadow.mapSize.height = 1024;
347
+ directionalLight.shadow.camera.near = 0.5;
348
+ directionalLight.shadow.camera.far = 50;
349
+
350
+ // === Room ===
351
+ const roomWidth = 20;
352
+ const roomHeight = 5;
353
+ const roomDepth = 30;
354
+ const floorGeometry = new THREE.PlaneGeometry(roomWidth, roomDepth);
355
+ const floorMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc, side: THREE.DoubleSide });
356
+ const floor = new THREE.Mesh(floorGeometry, floorMaterial);
357
+ floor.rotation.x = -Math.PI / 2;
358
+ floor.position.y = -roomHeight / 2;
359
+ floor.receiveShadow = true;
360
+ scene.add(floor);
361
+
362
+ // === Constants ===
363
+ const playerEyeHeight = 1.7;
364
+ const MAX_ARCHIVES_TO_LOAD = 30; // Limit number of archives
365
+ const TEMP_IDEAL_MIN = 18;
366
+ const TEMP_IDEAL_MAX = 24;
367
+ const TEMP_COLD = 15;
368
+ const TEMP_HOT = 27;
369
+ const ANOMALY_SHELF_INDICES = [5, 15]; // *** Indices of shelves with anomalies ***
370
+ const GRID_RESOLUTION = 0.5; // Size of each grid cell for pathfinding
371
+
372
+ // === Global Data Storage ===
373
+ let archiveData = []; // Use let to allow reassignment
374
+ const boxes = [];
375
+ const shelves = [];
376
+ const shelfLabels = [];
377
+ const rails = [];
378
+ let totalBoxesCreated = 0; // Track total boxes physically created
379
+ let occupiedBoxesCount = 0; // Track boxes with actual data
380
+ const retrievalTimes = []; // Array to store recent retrieval times
381
+ const maxRetrievalTimes = 5; // Number of times to average over
382
+ const walkingSpeed = 1.5; // Units per second (adjust as needed)
383
+ const fixedPickTime = 5; // Fixed seconds for picking the box (adjust as needed)
384
+ const accessFrequency = {}; // { locationId: count }
385
+ const aisleAccessFrequency = {}; // { aisleZ: count }
386
+ const shelfPanels = []; // *** Array to store all shelf panels for raycasting ***
387
+ let navigationGrid = []; // *** Grid for A* pathfinding ***
388
+ let gridWidth = 0;
389
+ let gridHeight = 0;
390
+ let gridOriginOffset = { x: 0, z: 0 };
391
+
392
+ // === Raycasting & Hover ===
393
+ const raycaster = new THREE.Raycaster();
394
+ const mouse = new THREE.Vector2();
395
+ let hoveredShelf = null;
396
+ const tempTooltipElement = document.getElementById('temp-tooltip'); // Get tooltip div
397
+
398
+ // === Default Dummy Data (Updated & Mapped) ===
399
+ // Function to generate box IDs sequentially
400
+ function generateBoxIds(rows, shelvesPerRow, levels, boxesPerLevel) {
401
+ const ids = [];
402
+ for (let r = 1; r <= rows; r++) {
403
+ for (let s = 1; s <= shelvesPerRow; s++) {
404
+ for (let i = 1; i <= levels; i++) {
405
+ for (let b = 1; b <= boxesPerLevel; b++) {
406
+ ids.push(`B${r}-R${s}-S${i}-N${b}`);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ return ids;
412
+ }
413
+
414
+ // Raw data extracted from PDF (first 30 entries)
415
+ const rawPdfData = [
416
+ { no: 1, sub: "Agenda", uraian: "Mijnwezen Agenda A Directie 1902. Nummer 56-28271.", kurun: "1 Januari 1902-14 Oktober 1902", kondisi: "Licht beschadigd" },
417
+ { no: 2, sub: "Agenda", uraian: "Agenda AFD M 1905-1906, No. 1-939 (1905), 1-883 (1906).", kurun: "2 Januari 1905-3 Januari 1907", kondisi: "Goed" },
418
+ { no: 3, sub: "Agenda", uraian: "Agenda V 1930. Doorlopend nummer 8191-10530. (2590).", kurun: "6 Mei 1930-13 Juni 1930", kondisi: "Goed" },
419
+ { no: 4, sub: "Agenda", uraian: "Agenda 1934. Doorloppend nummer 1-185.", kurun: "15 Januari 1934-29 Desember 1934", kondisi: "Licht beschadigd" },
420
+ { no: 5, sub: "Agenda", uraian: "Agenda A 1935. Doorloppend nummer 1-204. (2595).", kurun: "3 Januari 1935-23 Desember 1935", kondisi: "Goed" },
421
+ { no: 6, sub: "Agenda", uraian: "Agenda A 1936. Doorloopend nummer 1-246. (2506). (2596)", kurun: "16 Januari 1936-31 Desember 1936", kondisi: "Zwaar licht beschadigd, fungus" },
422
+ { no: 7, sub: "Agenda", uraian: "Agenda A 1937. Doorloopend nummer 1-487. (2597).", kurun: "2 Januari 1937-30 Desember 1937", kondisi: "Goed licht beschadigd, fungus" },
423
+ { no: 8, sub: "Agenda", uraian: "Agenda A 1938. Doorloopend nummer 1-503. (2598).", kurun: "2 Januari 1938-31 Desember 1938", kondisi: "Zwaar beschadigd, fungus" },
424
+ { no: 9, sub: "Agenda", uraian: "Agenda A 1939. Doorloopend nummer 1-486.", kurun: "2 Januari 1939-28 Desember 1939", kondisi: "Zwaar beschadigd, fungus" },
425
+ { no: 10, sub: "Agenda", uraian: "Agenda A 1940. Doorloopend nummer 1-414. (2600).", kurun: "2 Januari 1940-31 Desember 1940", kondisi: "Zwaar beschadigd, fungus" },
426
+ { no: 11, sub: "Agendaboek", uraian: "Inkomende en uitgaande Agenda P20, Kaulbach-systeem 1-25, Deel I en II, 1950.", kurun: "1 Januari 1950-17 Desember 1950", kondisi: "Goed" },
427
+ { no: 12, sub: "Agendaboek", uraian: "Inkomende (Agenda Boek) 1950, Nummer 1-1972.", kurun: "28 Januari 1950 - 16 November 1950", kondisi: "Goed" },
428
+ { no: 13, sub: "Agendaboek", uraian: "Inkomende en uitgaande Agenda D1-E1-F1-G1, Kaulbach-systeem 1-25, Deel I en II, 1950.", kurun: "28 Desember 1950-21 Desember 1951", kondisi: "Fungus" },
429
+ { no: 14, sub: "Bundel Tecnish Economische", uraian: "Bundel Technish Economische Afdeeling Alfabetis A-Z.", kurun: "1923-1923", kondisi: "Licht beschadigd" },
430
+ { no: 15, sub: "Bundel Tecnish Economische", uraian: "Bundel Technish Economische Afdeeling Maand A-Z, Maand Bangkatinwinning, Maand Oimbilinmijnen, Maand Boekit Asammijnen, Maand Brikettenfabriek, Maand Goudon Tambak Sawah, Maand Tinrestriche, Maand Gem bijnks bij, Maand P. Laoetmijnen, Maand Hoofdkantoor Mijnbouw.", kurun: "8 April 1905-25 April 1905", kondisi: "Licht beschadigd" },
431
+ { no: 16, sub: "Controle Agenda", uraian: "Controle 1909. VI-M 2. 63. (2569). Doorloopend controle nummer 1-544.", kurun: "6 September 1909-13 Januari 1911", kondisi: "Licht beschadigd, mieren" },
432
+ { no: 17, sub: "Controle Agenda", uraian: "Controle Agenda M 1928. VI-M 4. No. 1-22110.", kurun: "1928-1928", kondisi: "Licht beschadigd, fungus" },
433
+ { no: 18, sub: "Controle Agenda", uraian: "Controle Agenda 1929. VI-M 5. No. 86957-113757 (di sampul). No. 1-19040.", kurun: "Januari 1929-1929", kondisi: "Licht beschadigd, fungus" },
434
+ { no: 19, sub: "Controle Agenda", uraian: "Controle Agenda 1931. VI.M 6, nummer 1-16654.", kurun: "1931-1931", kondisi: "Zwaar beschadigd, fungus" },
435
+ { no: 20, sub: "Controle Agenda", uraian: "Controle Agenda 1934-1935. VI-M 7. No. 1-16230 (1934). No. 1-16503 (1935)", kurun: "1934-1935", kondisi: "Licht beschadigd, fungus" },
436
+ { no: 21, sub: "Controle Verbalen", uraian: "Controle Verbalen 1927, Nummer 1-11780", kurun: "1927-1927", kondisi: "Licht beschadigd" },
437
+ { no: 22, sub: "Expeditie", uraian: "Expeditie Tevens Brievenboek Afdeeling V.en Model Arch 8A Folio 4-163", kurun: "3 Januari 1939-3 Juli 1939", kondisi: "Zwaar beschadigd" },
438
+ { no: 23, sub: "Geheim Agenda", uraian: "Agende Geheim en verbalen 1903-1919.", kurun: "28 Februari 1903-9 Mei 1919", kondisi: "Goed" },
439
+ { no: 24, sub: "Geheim Agenda", uraian: "Agenda Geheim 1927, 1928, 1929, Doorlopend nummer 231-281 (1927), 1-281 (1928), 1-184 (1929)", kurun: "21 Juli 1927-8 Juni 1929", kondisi: "Goed" },
440
+ { no: 25, sub: "Geheim Agenda", uraian: "Agenda Geheim 1933-1937, Doorlopend nummer 1-754 (1933), 1-290 (1934), 1-374 (1935), 1-438 (1936), 1-200 (1937)", kurun: "30 Desember 1933-17 Juni 1937", kondisi: "Goed" },
441
+ { no: 26, sub: "Geheim Agenda", uraian: "Geheime Agenda 1937 t/m 1940. Agenda geheim. (2597/2600)", kurun: "1 Juli 1937-23 Desember 1940", kondisi: "Zwaar beschadigd, fungus" },
442
+ { no: 27, sub: "Geheime Directie-Verbalen", uraian: "Geheime Directie-Verbalen 1929 t/m 1942.", kurun: "2 Desember 1929-13 Februari 1942", kondisi: "Mieren" },
443
+ { no: 28, sub: "Index", uraian: "Index Besluiten Registen Opsporingsdienst 3, Alfabetis A-W", kurun: "TT", kondisi: "Goed" },
444
+ { no: 29, sub: "Index", uraian: "Index Controle 1919 Deel VI-M.3 Alfabetis A-W", kurun: "1919-1919", kondisi: "Licht beschadigd, fungus" },
445
+ { no: 30, sub: "Index", uraian: "Index Folio Directie Deel I, VI-I, 49, Bladzijde 1-399, 1925", kurun: "12 Desember 1924-26 Mei 1925", kondisi: "Licht beschadigd" }
446
+ ];
447
+
448
+ // Function to clean and format raw data, now assigning varied locations
449
+ function processRawData(rawData, boxIds) {
450
+ const processedData = [];
451
+ const count = Math.min(rawData.length, MAX_ARCHIVES_TO_LOAD); // Limit to 30
452
+
453
+ // Define specific, varied box locations for the 30 archives
454
+ const variedLocations = [
455
+ 'B1-R1-S1-N1', 'B1-R1-S3-N3', 'B1-R1-S5-N2',
456
+ 'B1-R3-S2-N4', 'B1-R3-S4-N1', 'B1-R5-S1-N3',
457
+ 'B1-R5-S3-N1', 'B2-R2-S2-N2', 'B2-R2-S4-N4',
458
+ 'B2-R2-S5-N1', 'B2-R4-S1-N1', 'B2-R4-S3-N3',
459
+ 'B2-R6-S2-N1', 'B2-R6-S5-N4', 'B3-R1-S1-N4',
460
+ 'B3-R1-S4-N2', 'B3-R3-S3-N1', 'B3-R3-S5-N3',
461
+ 'B3-R5-S2-N2', 'B3-R5-S4-N4', 'B4-R2-S1-N3',
462
+ 'B4-R2-S3-N1', 'B4-R2-S5-N4', 'B4-R4-S2-N1',
463
+ 'B4-R4-S4-N3', 'B4-R6-S1-N2', 'B4-R6-S3-N4',
464
+ 'B4-R6-S5-N1', 'B1-R2-S1-N1', 'B2-R3-S1-N2'
465
+ ];
466
+
467
+ for (let index = 0; index < count; index++) {
468
+ const item = rawData[index];
469
+ const nama_arsip = item.uraian?.replace(/\n/g, ' ').replace(/"/g, '').trim() || `Arsip Tidak Bernama ${index + 1}`;
470
+ const deskripsi = item.sub?.replace(/\n/g, ' ').replace(/"/g, '').trim() || "Tidak Ada Deskripsi";
471
+ const kondisi = item.kondisi?.replace(/\n/g, ' ').replace(/"/g, '').trim() || "Tidak Diketahui";
472
+ let tahun = "TT";
473
+ if (item.kurun && typeof item.kurun === 'string') {
474
+ const yearMatch = item.kurun.match(/\d{4}/g);
475
+ if (yearMatch) {
476
+ if (yearMatch.length > 1) {
477
+ const startYear = parseInt(yearMatch[0]);
478
+ const endYear = parseInt(yearMatch[yearMatch.length - 1]);
479
+ tahun = (startYear <= endYear) ? `${startYear}-${endYear}` : `${endYear}-${startYear}`;
480
+ } else {
481
+ tahun = yearMatch[0];
482
+ }
483
+ } else if (item.kurun.trim().toUpperCase() !== 'TT') {
484
+ tahun = item.kurun.trim();
485
+ }
486
+ }
487
+ processedData.push({
488
+ id: `ARSIP-${String(index + 1).padStart(3, '0')}`,
489
+ nama_arsip: nama_arsip,
490
+ deskripsi: deskripsi,
491
+ tahun: tahun,
492
+ kategori: deskripsi,
493
+ lokasi_rak: variedLocations[index], // Assign varied location
494
+ kondisi: kondisi,
495
+ format: "Kertas"
496
+ });
497
+ }
498
+ return processedData;
499
+ }
500
+
501
+ // === Shelves and Boxes (Roll O Pack Style - Facing Each Other) ===
502
+ const boxGeometry = new THREE.BoxGeometry(0.4, 0.3, 0.5);
503
+ const boxMaterial = new THREE.MeshStandardMaterial({ color: 0xdeb887 });
504
+ const shelfWidth = 2;
505
+ const shelfDepth = 0.6;
506
+ const shelfHeight = 2;
507
+ const shelfLevels = 5;
508
+ const shelfBoardThickness = 0.05;
509
+ const panelThickness = 0.05;
510
+ const shelfColor = 0xAAAAAA;
511
+ const panelColor = 0x999999;
512
+ const handleColor = 0x555555;
513
+ const railColor = 0x777777;
514
+ const numShelfRows = 4;
515
+ const shelvesPerDoubleRow = 6;
516
+ const rowSpacing = 4;
517
+ const pairSpacingZ = 2.0;
518
+ const aisleWidthFacing = 1.8;
519
+ const railHeight = 0.02;
520
+ const railWidth = 0.04;
521
+ const railSpacing = 0.4;
522
+ const boxesPerLevel = 4;
523
+
524
+ const numPairsPerRow = shelvesPerDoubleRow / 2;
525
+ const startZ = - (numPairsPerRow - 1) * pairSpacingZ / 2;
526
+ const startX = - (numShelfRows - 1) * rowSpacing / 2;
527
+
528
+ // Generate all possible box IDs based on the layout
529
+ const allBoxIds = generateBoxIds(numShelfRows, shelvesPerDoubleRow, shelfLevels, boxesPerLevel);
530
+
531
+ // Process the raw data (first 30 entries) and map it to the varied box IDs
532
+ let defaultDummyData = processRawData(rawPdfData, allBoxIds); // Use let
533
+
534
+ // --- Heatmap Colors ---
535
+ const colorCold = new THREE.Color(0x0000ff); // Blue
536
+ const colorIdeal = new THREE.Color(0x00ff00); // Green
537
+ const colorHot = new THREE.Color(0xff0000); // Red
538
+
539
+
540
+ function createSceneContent() {
541
+ // Clear existing objects
542
+ shelves.forEach(shelf => scene.remove(shelf));
543
+ boxes.forEach(box => { if (box.parent) box.parent.remove(box); });
544
+ shelfLabels.forEach(label => { label.element.remove(); if(label.parent) label.parent.remove(label); });
545
+ rails.forEach(rail => scene.remove(rail));
546
+ shelfPanels.length = 0; // *** Clear shelf panels array ***
547
+ shelves.length = 0;
548
+ boxes.length = 0;
549
+ shelfLabels.length = 0;
550
+ rails.length = 0;
551
+ let boxCreationCounter = 0;
552
+ let shelfCreationIndex = 0;
553
+
554
+ const shelfMaterial = new THREE.MeshStandardMaterial({ color: shelfColor });
555
+ const panelMaterial = new THREE.MeshStandardMaterial({ color: panelColor, vertexColors: false });
556
+ const handleMaterial = new THREE.MeshStandardMaterial({ color: handleColor });
557
+ const railMaterial = new THREE.MeshStandardMaterial({ color: railColor });
558
+
559
+ // --- Create Rails (Two per shelf) ---
560
+ const singleRailLength = shelfWidth;
561
+ const railGeometry = new THREE.BoxGeometry(singleRailLength, railHeight, railWidth);
562
+
563
+ for (let r = 0; r < numShelfRows; r++) {
564
+ const railX = startX + r * rowSpacing;
565
+ const railY = floor.position.y + railHeight / 2 + 0.001;
566
+
567
+ for (let p = 0; p < numPairsPerRow; p++) {
568
+ const pairCenterZ = startZ + p * pairSpacingZ;
569
+
570
+ // Rails for Left Shelf
571
+ const leftShelfCenterZ = pairCenterZ - aisleWidthFacing / 2;
572
+ const railLeftFront = new THREE.Mesh(railGeometry, railMaterial);
573
+ railLeftFront.position.set(railX, railY, leftShelfCenterZ + railSpacing / 2);
574
+ railLeftFront.receiveShadow = true;
575
+ scene.add(railLeftFront);
576
+ rails.push(railLeftFront);
577
+ const railLeftBack = new THREE.Mesh(railGeometry, railMaterial);
578
+ railLeftBack.position.set(railX, railY, leftShelfCenterZ - railSpacing / 2);
579
+ railLeftBack.receiveShadow = true;
580
+ scene.add(railLeftBack);
581
+ rails.push(railLeftBack);
582
+
583
+ // Rails for Right Shelf
584
+ const rightShelfCenterZ = pairCenterZ + aisleWidthFacing / 2;
585
+ const railRightFront = new THREE.Mesh(railGeometry, railMaterial);
586
+ railRightFront.position.set(railX, railY, rightShelfCenterZ + railSpacing / 2);
587
+ railRightFront.receiveShadow = true;
588
+ scene.add(railRightFront);
589
+ rails.push(railRightFront);
590
+ const railRightBack = new THREE.Mesh(railGeometry, railMaterial);
591
+ railRightBack.position.set(railX, railY, rightShelfCenterZ - railSpacing / 2);
592
+ railRightBack.receiveShadow = true;
593
+ scene.add(railRightBack);
594
+ rails.push(railRightBack);
595
+ }
596
+ }
597
+
598
+
599
+ // --- Create Shelves ---
600
+ for (let r = 0; r < numShelfRows; r++) {
601
+ for (let s = 0; s < shelvesPerDoubleRow; s++) {
602
+ const shelfGroup = new THREE.Group();
603
+ shelfGroup.userData.creationIndex = shelfCreationIndex++;
604
+ const shelfX = startX + r * rowSpacing;
605
+
606
+ const pairIndex = Math.floor(s / 2);
607
+ const isRightShelf = s % 2 !== 0;
608
+
609
+ const currentPairCenterZ = startZ + pairIndex * pairSpacingZ;
610
+ let shelfZ;
611
+ let rotationY = 0;
612
+
613
+ if (isRightShelf) {
614
+ shelfZ = currentPairCenterZ + aisleWidthFacing / 2;
615
+ rotationY = Math.PI;
616
+ } else {
617
+ shelfZ = currentPairCenterZ - aisleWidthFacing / 2;
618
+ rotationY = 0;
619
+ }
620
+
621
+ // --- Roll O Pack Structure ---
622
+ const sidePanelLeftMat = panelMaterial.clone();
623
+ const sidePanelRightMat = panelMaterial.clone();
624
+ const backPanelMat = panelMaterial.clone();
625
+ const topPanelMat = panelMaterial.clone();
626
+
627
+ const sidePanelLeftGeo = new THREE.BoxGeometry(panelThickness, shelfHeight, shelfDepth);
628
+ const sidePanelLeft = new THREE.Mesh(sidePanelLeftGeo, sidePanelLeftMat);
629
+ sidePanelLeft.position.set(-shelfWidth / 2 + panelThickness / 2, 0, 0);
630
+ sidePanelLeft.castShadow = true; sidePanelLeft.receiveShadow = true;
631
+ shelfGroup.add(sidePanelLeft);
632
+ shelfPanels.push(sidePanelLeft); // *** Add panel to array for raycasting ***
633
+
634
+ const sidePanelRightGeo = new THREE.BoxGeometry(panelThickness, shelfHeight, shelfDepth);
635
+ const sidePanelRight = new THREE.Mesh(sidePanelRightGeo, sidePanelRightMat);
636
+ sidePanelRight.position.set(shelfWidth / 2 - panelThickness / 2, 0, 0);
637
+ sidePanelRight.castShadow = true; sidePanelRight.receiveShadow = true;
638
+ shelfGroup.add(sidePanelRight);
639
+ shelfPanels.push(sidePanelRight); // *** Add panel to array for raycasting ***
640
+
641
+ const backPanelGeo = new THREE.BoxGeometry(shelfWidth - 2 * panelThickness, shelfHeight, panelThickness);
642
+ const backPanel = new THREE.Mesh(backPanelGeo, backPanelMat);
643
+ backPanel.position.set(0, 0, -shelfDepth / 2 + panelThickness / 2);
644
+ backPanel.castShadow = true; backPanel.receiveShadow = true;
645
+ shelfGroup.add(backPanel);
646
+ shelfPanels.push(backPanel); // *** Add panel to array for raycasting ***
647
+
648
+ const topPanelGeo = new THREE.BoxGeometry(shelfWidth - 2* panelThickness, panelThickness, shelfDepth);
649
+ const topPanel = new THREE.Mesh(topPanelGeo, topPanelMat);
650
+ topPanel.position.set(0, shelfHeight / 2 - panelThickness / 2, 0);
651
+ topPanel.castShadow = true; topPanel.receiveShadow = true;
652
+ shelfGroup.add(topPanel);
653
+ shelfPanels.push(topPanel); // *** Add panel to array for raycasting ***
654
+
655
+ // Store references to the panel meshes in the group's userData for easy access
656
+ shelfGroup.userData.panels = [sidePanelLeft, sidePanelRight, backPanel, topPanel];
657
+ shelfGroup.userData.originalPanelMaterial = panelMaterial; // Store the base material
658
+
659
+ // *** Assign initial temperature based on anomaly list ***
660
+ if (ANOMALY_SHELF_INDICES.includes(shelfGroup.userData.creationIndex)) {
661
+ shelfGroup.userData.temperature = (shelfGroup.userData.creationIndex === 5)
662
+ ? TEMP_HOT - Math.random() * 3 // Hotter anomaly
663
+ : TEMP_COLD + Math.random() * 3; // Colder anomaly
664
+ shelfGroup.userData.isAnomalous = true;
665
+ } else {
666
+ shelfGroup.userData.temperature = baseTemp + (Math.random() * 4 - 2); // Normal initial temp
667
+ shelfGroup.userData.isAnomalous = false;
668
+ }
669
+
670
+
671
+ // --- Add Rotating Handle ---
672
+ const handleRadius = 0.08;
673
+ const handleHeight = 0.05;
674
+ const handleSegments = 16;
675
+ const handleGeometry = new THREE.CylinderGeometry(handleRadius, handleRadius, handleHeight, handleSegments);
676
+ const handle = new THREE.Mesh(handleGeometry, handleMaterial);
677
+ const handleX = isRightShelf
678
+ ? (shelfWidth / 2 - panelThickness / 2 - handleHeight / 2)
679
+ : (-shelfWidth / 2 + panelThickness / 2 + handleHeight / 2);
680
+ const handleY = 0;
681
+ const handleZ = shelfDepth / 2 - handleRadius * 1.5;
682
+ handle.position.set(handleX, handleY, handleZ);
683
+ handle.rotation.z = Math.PI / 2;
684
+ handle.castShadow = true;
685
+ shelfGroup.add(handle);
686
+
687
+
688
+ // --- Shelf Boards and Boxes ---
689
+ const boardWidth = shelfWidth - 2 * panelThickness;
690
+ const boardGeometry = new THREE.BoxGeometry(boardWidth, shelfBoardThickness, shelfDepth - panelThickness);
691
+ for (let i = 0; i < shelfLevels; i++) {
692
+ const boardY = -shelfHeight / 2 + shelfBoardThickness / 2 + i * (shelfHeight / (shelfLevels)); // Adjusted spacing slightly
693
+ const board = new THREE.Mesh(boardGeometry, shelfMaterial.clone()); // Clone shelf material too if needed
694
+ board.position.set(0, boardY, 0);
695
+ board.castShadow = true; board.receiveShadow = true;
696
+ shelfGroup.add(board);
697
+
698
+ const boxStartX = -boardWidth / 2 + 0.25;
699
+ const boxSpacingX = 0.5;
700
+ for (let b = 0; b < boxesPerLevel; b++) {
701
+ // Use the pre-generated ID based on the overall counter
702
+ const boxId = allBoxIds[boxCreationCounter];
703
+ // Only create a box if an ID exists for it
704
+ if (!boxId) break;
705
+
706
+ if (boxStartX + b * boxSpacingX + 0.4 / 2 > boardWidth / 2) continue;
707
+
708
+ const box = new THREE.Mesh(boxGeometry, boxMaterial.clone());
709
+ const boxX = boxStartX + b * boxSpacingX;
710
+ const boxY = boardY + shelfBoardThickness / 2 + 0.3 / 2;
711
+ const boxZ = 0;
712
+ box.position.set(boxX, boxY, boxZ);
713
+ box.castShadow = true; box.receiveShadow = true;
714
+
715
+ box.userData = {
716
+ id: boxId, // Assign the correct sequential ID
717
+ originalMaterial: boxMaterial.clone(),
718
+ archiveInfo: null, // Will be linked by linkArchiveDataToBoxes
719
+ isBlinking: false
720
+ };
721
+ shelfGroup.add(box);
722
+ boxes.push(box);
723
+ boxCreationCounter++; // Increment the counter for the next box
724
+ }
725
+ if (boxCreationCounter >= allBoxIds.length) break; // Stop creating levels if done
726
+ }
727
+ if (boxCreationCounter >= allBoxIds.length) break; // Stop creating shelves if done
728
+
729
+
730
+ // --- Add Shelf Label ---
731
+ const shelfLabelDiv = document.createElement('div');
732
+ shelfLabelDiv.className = 'shelf-label';
733
+ shelfLabelDiv.textContent = `Rak B${r + 1}-R${s + 1}`;
734
+ const shelfLabel = new THREE.CSS2DObject(shelfLabelDiv);
735
+ const labelZOffset = shelfDepth / 2 + 0.1;
736
+ shelfLabel.position.set(0, shelfHeight / 2 + 0.2, labelZOffset);
737
+ shelfGroup.add(shelfLabel);
738
+ shelfLabels.push(shelfLabel);
739
+
740
+
741
+ // Position and rotate the entire shelf group
742
+ shelfGroup.position.set(shelfX, -roomHeight / 2 + shelfHeight / 2, shelfZ);
743
+ shelfGroup.rotation.y = rotationY;
744
+ scene.add(shelfGroup);
745
+ shelves.push(shelfGroup);
746
+ }
747
+ if (boxCreationCounter >= allBoxIds.length) break; // Stop creating rows if done
748
+ }
749
+ totalBoxesCreated = boxes.length; // Store the total number of boxes created
750
+ console.log(`Created ${shelves.length} shelves, ${totalBoxesCreated} boxes, ${shelfLabels.length} labels, and ${rails.length} rail segments.`);
751
+ linkArchiveDataToBoxes(); // Link the defaultDummyData (now limited to 30)
752
+ populateArchiveList(archiveData); // Populate list with the limited data
753
+ buildNavigationGrid(); // *** Build the grid after creating shelves ***
754
+ }
755
+
756
+ // === Archive List Population ===
757
+ const archiveListElement = document.getElementById('archive-list');
758
+ function populateArchiveList(data) { /* ... (populateArchiveList function remains the same) ... */
759
+ archiveListElement.innerHTML = '';
760
+ if (!archiveData || archiveData.length === 0) {
761
+ archiveListElement.innerHTML = '<li>Data arsip kosong. Muat ulang atau unggah file JSON.</li>';
762
+ return;
763
+ }
764
+ archiveData.forEach(item => {
765
+ if (item.nama_arsip && item.lokasi_rak) {
766
+ const listItem = document.createElement('li');
767
+ listItem.textContent = item.nama_arsip;
768
+ listItem.title = `${item.nama_arsip} (ID: ${item.id || 'N/A'}, Lokasi: ${item.lokasi_rak})`;
769
+ listItem.dataset.location = item.lokasi_rak;
770
+ listItem.dataset.archiveId = item.id || '';
771
+ archiveListElement.appendChild(listItem);
772
+ }
773
+ });
774
+ }
775
+
776
+ // === Link Archive Data to 3D Boxes ===
777
+ function linkArchiveDataToBoxes() { /* ... (linkArchiveDataToBoxes function remains the same) ... */
778
+ occupiedBoxesCount = 0;
779
+ if (!archiveData || archiveData.length === 0) {
780
+ console.log("No archive data to link.");
781
+ updateStorageInfo();
782
+ return;
783
+ }
784
+ boxes.forEach(box => {
785
+ box.userData.archiveInfo = null;
786
+ box.userData.isBlinking = false;
787
+ if (box.material !== box.userData.originalMaterial && highlightedBox !== box) {
788
+ if(box.userData.originalMaterial) {
789
+ box.material = box.userData.originalMaterial;
790
+ } else {
791
+ box.material = boxMaterial;
792
+ }
793
+ }
794
+ });
795
+
796
+ let linkedCount = 0;
797
+ archiveData.forEach(item => {
798
+ if (item.lokasi_rak) {
799
+ const targetBox = boxes.find(box => box.userData.id.toUpperCase() === item.lokasi_rak.toUpperCase());
800
+ if (targetBox) {
801
+ targetBox.userData.archiveInfo = item;
802
+ linkedCount++;
803
+ }
804
+ } else {
805
+ console.warn(`Archive item "${item.nama_arsip || item.id}" is missing 'lokasi_rak'.`);
806
+ }
807
+ });
808
+ occupiedBoxesCount = linkedCount;
809
+ console.log(`Linked ${linkedCount} archive data items to ${boxes.length} 3D boxes.`);
810
+ updateStorageInfo();
811
+ }
812
+
813
+ // === Controls ===
814
+ const orbitControls = new THREE.OrbitControls(camera, renderer.domElement); /* ... (OrbitControls setup remains the same) ... */
815
+ orbitControls.enableDamping = true;
816
+ orbitControls.dampingFactor = 0.05;
817
+ orbitControls.screenSpacePanning = false;
818
+ orbitControls.maxPolarAngle = Math.PI / 1.9;
819
+ orbitControls.minDistance = 1;
820
+ orbitControls.maxDistance = 50;
821
+ orbitControls.target.set(0, 0, 0);
822
+
823
+ const pointerLockControls = new THREE.PointerLockControls(camera, document.body); /* ... (PointerLockControls setup remains the same) ... */
824
+ scene.add(pointerLockControls.getObject());
825
+
826
+ let moveForward = false, moveBackward = false, moveLeft = false, moveRight = false;
827
+ const velocity = new THREE.Vector3();
828
+ const direction = new THREE.Vector3();
829
+ const moveSpeed = 5.0;
830
+
831
+ const onKeyDown = (event) => { if (!pointerLockControls.isLocked) return; switch (event.code) { case 'ArrowUp': case 'KeyW': moveForward = true; break; case 'ArrowLeft': case 'KeyA': moveLeft = true; break; case 'ArrowDown': case 'KeyS': moveBackward = true; break; case 'ArrowRight': case 'KeyD': moveRight = true; break; } };
832
+ const onKeyUp = (event) => { switch (event.code) { case 'ArrowUp': case 'KeyW': moveForward = false; break; case 'ArrowLeft': case 'KeyA': moveLeft = false; break; case 'ArrowDown': case 'KeyS': moveBackward = false; break; case 'ArrowRight': case 'KeyD': moveRight = false; break; } };
833
+ document.addEventListener('keydown', onKeyDown);
834
+ document.addEventListener('keyup', onKeyUp);
835
+
836
+ pointerLockControls.addEventListener('lock', () => { /* instructions.style.display = 'none'; */ controlsContainer.style.display = 'none'; archiveListContainer.style.display = 'none'; labelRenderer.domElement.style.display = 'none'; environmentInfoPanel.style.display = 'none'; analyticsPanel.style.display = 'none'; }); // Hide panels in FPV
837
+ pointerLockControls.addEventListener('unlock', () => { /* instructions.style.display = 'block'; */ controlsContainer.style.display = 'flex'; archiveListContainer.style.display = 'block'; moveForward = moveBackward = moveLeft = moveRight = false; velocity.set(0, 0, 0); labelRenderer.domElement.style.display = 'block'; environmentInfoPanel.style.display = 'block'; analyticsPanel.style.display = 'block'; }); // Show panels in Orbit
838
+ renderer.domElement.addEventListener('click', () => { if (!isOrbitView && !pointerLockControls.isLocked) pointerLockControls.lock(); });
839
+
840
+ // === View Toggle ===
841
+ let isOrbitView = true;
842
+ const toggleViewButton = document.getElementById('toggle-view-button');
843
+ // const instructions = document.getElementById('instructions'); // Removed reference
844
+ const controlsContainer = document.getElementById('controls-container');
845
+ const archiveListContainer = document.getElementById('archive-list-container');
846
+ const environmentInfoPanel = document.getElementById('environment-info');
847
+ const analyticsPanel = document.getElementById('analytics-panel'); // Get reference
848
+
849
+ camera.position.set(roomWidth * 0.6, roomHeight * 1.2, roomDepth * 0.6);
850
+ orbitControls.update();
851
+
852
+ toggleViewButton.addEventListener('click', () => {
853
+ isOrbitView = !isOrbitView;
854
+ if (isOrbitView) {
855
+ // Switch TO Orbit View
856
+ if (pointerLockControls.isLocked) pointerLockControls.unlock(); // Unlock first
857
+ pointerLockControls.enabled = false;
858
+ orbitControls.enabled = true;
859
+ archiveListContainer.style.display = 'block';
860
+ labelRenderer.domElement.style.display = 'block'; // Show labels
861
+ environmentInfoPanel.style.display = 'block'; // Show env info
862
+ analyticsPanel.style.display = 'block'; // Show analytics
863
+ toggleViewButton.textContent = 'Ganti ke Mode Jalan';
864
+ // instructions.innerHTML = `...`; // Removed update
865
+ const fpvPosition = pointerLockControls.getObject().position;
866
+ camera.position.copy(fpvPosition);
867
+ camera.position.y = Math.max(camera.position.y, -roomHeight / 2 + 0.5);
868
+ const lookDirection = new THREE.Vector3();
869
+ camera.getWorldDirection(lookDirection);
870
+ orbitControls.target.copy(fpvPosition).add(lookDirection.multiplyScalar(2));
871
+ orbitControls.update();
872
+ } else {
873
+ // Switch TO First-Person View
874
+ orbitControls.enabled = false;
875
+ pointerLockControls.enabled = true;
876
+ archiveListContainer.style.display = 'none';
877
+ labelRenderer.domElement.style.display = 'none'; // Hide labels
878
+ environmentInfoPanel.style.display = 'none'; // Hide env info
879
+ analyticsPanel.style.display = 'none'; // Hide analytics
880
+ toggleViewButton.textContent = 'Ganti ke Mode Orbit';
881
+ // instructions.innerHTML = `...`; // Removed update
882
+ pointerLockControls.getObject().position.y = (-roomHeight / 2) + playerEyeHeight;
883
+ pointerLockControls.getObject().position.x = camera.position.x;
884
+ pointerLockControls.getObject().position.z = camera.position.z;
885
+ }
886
+ });
887
+
888
+ // === JSON File Upload (REMOVED) ===
889
+ // const jsonUploadInput = document.getElementById('json-upload');
890
+ // jsonUploadInput.addEventListener('change', (event) => { ... });
891
+
892
+ // === A* Pathfinding Implementation ===
893
+ class PathNode {
894
+ constructor(x, z, g = 0, h = 0, parent = null) {
895
+ this.x = x; // Grid x coordinate
896
+ this.z = z; // Grid z coordinate (using z for grid height)
897
+ this.g = g; // Cost from start
898
+ this.h = h; // Heuristic cost to end
899
+ this.f = g + h; // Total cost
900
+ this.parent = parent; // Parent node for path reconstruction
901
+ }
902
+ // Helper to check if two nodes are the same position
903
+ equals(otherNode) {
904
+ return this.x === otherNode.x && this.z === otherNode.z;
905
+ }
906
+ }
907
+
908
+ // Heuristic function (Manhattan distance)
909
+ function heuristic(nodeA, nodeB) {
910
+ return Math.abs(nodeA.x - nodeB.x) + Math.abs(nodeA.z - nodeB.z);
911
+ }
912
+
913
+ function findPathAStar(startX, startZ, endX, endZ) {
914
+ const startNode = new PathNode(startX, startZ);
915
+ const endNode = new PathNode(endX, endZ);
916
+
917
+ const openList = [startNode];
918
+ const closedList = [];
919
+
920
+ while (openList.length > 0) {
921
+ // Find node with lowest f score in openList
922
+ let lowestFIndex = 0;
923
+ for (let i = 1; i < openList.length; i++) {
924
+ if (openList[i].f < openList[lowestFIndex].f) {
925
+ lowestFIndex = i;
926
+ }
927
+ }
928
+ const currentNode = openList[lowestFIndex];
929
+
930
+ // Move current node from open to closed list
931
+ openList.splice(lowestFIndex, 1);
932
+ closedList.push(currentNode);
933
+
934
+ // Check if we reached the end
935
+ if (currentNode.equals(endNode)) {
936
+ // Reconstruct path
937
+ const path = [];
938
+ let temp = currentNode;
939
+ while (temp !== null) {
940
+ path.push({ x: temp.x, z: temp.z });
941
+ temp = temp.parent;
942
+ }
943
+ return path.reverse(); // Return path from start to end
944
+ }
945
+
946
+ // Get neighbors (up, down, left, right)
947
+ const neighbors = [];
948
+ const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]; // Right, Left, Down, Up
949
+ for (const dir of directions) {
950
+ const neighborX = currentNode.x + dir[0];
951
+ const neighborZ = currentNode.z + dir[1];
952
+
953
+ // Check bounds and if walkable
954
+ if (neighborX >= 0 && neighborX < gridWidth &&
955
+ neighborZ >= 0 && neighborZ < gridHeight &&
956
+ navigationGrid[neighborZ][neighborX] === 0) { // 0 means walkable
957
+ neighbors.push(new PathNode(neighborX, neighborZ));
958
+ }
959
+ }
960
+
961
+ // Process neighbors
962
+ for (const neighbor of neighbors) {
963
+ // Skip if neighbor is in closed list
964
+ if (closedList.some(node => node.equals(neighbor))) {
965
+ continue;
966
+ }
967
+
968
+ const gScore = currentNode.g + 1; // Assuming cost of 1 for adjacent move
969
+ let gScoreIsBest = false;
970
+
971
+ // Check if neighbor is not in open list
972
+ const openNodeIndex = openList.findIndex(node => node.equals(neighbor));
973
+ if (openNodeIndex === -1) {
974
+ gScoreIsBest = true;
975
+ neighbor.h = heuristic(neighbor, endNode);
976
+ openList.push(neighbor);
977
+ } else if (gScore < openList[openNodeIndex].g) {
978
+ gScoreIsBest = true;
979
+ }
980
+
981
+ if (gScoreIsBest) {
982
+ neighbor.parent = currentNode;
983
+ neighbor.g = gScore;
984
+ neighbor.f = neighbor.g + neighbor.h;
985
+ // If neighbor was already in openList, update it (needed if using Priority Queue)
986
+ // For simple array, adding again works but is less efficient.
987
+ // If already in open list and score improved, update its parent and scores.
988
+ if (openNodeIndex !== -1) {
989
+ openList[openNodeIndex] = neighbor;
990
+ }
991
+ }
992
+ }
993
+ }
994
+
995
+ // No path found
996
+ console.warn("A* Pathfinding: No path found!");
997
+ return null;
998
+ }
999
+
1000
+ // Function to convert world coordinates to grid coordinates
1001
+ function worldToGrid(worldX, worldZ) {
1002
+ const gridX = Math.floor((worldX - gridOriginOffset.x) / GRID_RESOLUTION);
1003
+ const gridZ = Math.floor((worldZ - gridOriginOffset.z) / GRID_RESOLUTION);
1004
+ // Clamp to grid boundaries
1005
+ const clampedX = Math.max(0, Math.min(gridWidth - 1, gridX));
1006
+ const clampedZ = Math.max(0, Math.min(gridHeight - 1, gridZ));
1007
+ return { x: clampedX, z: clampedZ };
1008
+ }
1009
+
1010
+ // Function to convert grid coordinates back to world coordinates (center of cell)
1011
+ function gridToWorld(gridX, gridZ) {
1012
+ const worldX = gridX * GRID_RESOLUTION + gridOriginOffset.x + GRID_RESOLUTION / 2;
1013
+ const worldZ = gridZ * GRID_RESOLUTION + gridOriginOffset.z + GRID_RESOLUTION / 2;
1014
+ return new THREE.Vector3(worldX, -roomHeight / 2 + 0.1, worldZ); // Y slightly above floor
1015
+ }
1016
+
1017
+ // Function to build the navigation grid
1018
+ function buildNavigationGrid() {
1019
+ gridWidth = Math.ceil(roomWidth / GRID_RESOLUTION);
1020
+ gridHeight = Math.ceil(roomDepth / GRID_RESOLUTION);
1021
+ gridOriginOffset.x = -roomWidth / 2;
1022
+ gridOriginOffset.z = -roomDepth / 2;
1023
+
1024
+ // Initialize grid with 0 (walkable)
1025
+ navigationGrid = Array(gridHeight).fill(null).map(() => Array(gridWidth).fill(0));
1026
+
1027
+ // Mark cells occupied by shelves as 1 (blocked)
1028
+ shelves.forEach(shelf => {
1029
+ const shelfPos = shelf.position;
1030
+ const halfShelfWidth = shelfWidth / 2;
1031
+ const halfShelfDepth = shelfDepth / 2; // Use actual shelf depth
1032
+
1033
+ // Calculate bounding box in world coordinates, considering rotation
1034
+ // Create a temporary Box3 helper
1035
+ const boxHelper = new THREE.Box3();
1036
+ // Set the helper from the shelf group (which includes all panels)
1037
+ // This automatically considers the group's position and rotation
1038
+ boxHelper.setFromObject(shelf);
1039
+
1040
+ // Convert world bounds to grid bounds
1041
+ const minGrid = worldToGrid(boxHelper.min.x, boxHelper.min.z);
1042
+ const maxGrid = worldToGrid(boxHelper.max.x, boxHelper.max.z);
1043
+
1044
+
1045
+ // Mark grid cells within the bounds as blocked
1046
+ for (let z = minGrid.z; z <= maxGrid.z; z++) {
1047
+ for (let x = minGrid.x; x <= maxGrid.x; x++) {
1048
+ if (x >= 0 && x < gridWidth && z >= 0 && z < gridHeight) {
1049
+ navigationGrid[z][x] = 1; // Mark as blocked
1050
+ }
1051
+ }
1052
+ }
1053
+ });
1054
+ console.log("Navigation grid built.");
1055
+ // console.log(navigationGrid); // Optional: Log grid for debugging
1056
+ }
1057
+
1058
+
1059
+ // === Pathfinding and Highlighting Logic (dipicu oleh klik daftar) ===
1060
+ let highlightedBox = null;
1061
+ let pathLine = null;
1062
+ const blinkMaterial = new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0x555500 });
1063
+ const pathMaterialBlue = new THREE.LineBasicMaterial({ color: 0x0000ff, linewidth: 5 });
1064
+ const avgRetrievalTimeElement = document.getElementById('avg-retrieval-time');
1065
+
1066
+ function findAndShowPath(locationId) {
1067
+ const normalizedLocationId = locationId.trim().toUpperCase();
1068
+ if (!normalizedLocationId) return;
1069
+
1070
+ // Reset previous highlight and path
1071
+ if (highlightedBox) {
1072
+ highlightedBox.userData.isBlinking = false;
1073
+ if (highlightedBox.userData.originalMaterial) {
1074
+ highlightedBox.material = highlightedBox.userData.originalMaterial;
1075
+ } else {
1076
+ highlightedBox.material = boxMaterial;
1077
+ }
1078
+ const parentShelf = highlightedBox.parent;
1079
+ if (parentShelf && parentShelf.userData.panels) {
1080
+ const tempColor = getTemperatureColor(parentShelf.userData.temperature);
1081
+ parentShelf.userData.panels.forEach(panel => {
1082
+ panel.material.color.copy(tempColor);
1083
+ });
1084
+ }
1085
+ highlightedBox = null;
1086
+ }
1087
+ if (pathLine) {
1088
+ scene.remove(pathLine);
1089
+ pathLine.geometry.dispose();
1090
+ pathLine = null;
1091
+ }
1092
+ const currentlySelected = archiveListElement.querySelector('.selected');
1093
+ if (currentlySelected) {
1094
+ currentlySelected.classList.remove('selected');
1095
+ }
1096
+
1097
+ let foundBox = null;
1098
+ let archiveInfo = null;
1099
+ const pathMaterial = pathMaterialBlue;
1100
+
1101
+ foundBox = boxes.find(box => box.userData.id.toUpperCase() === normalizedLocationId);
1102
+
1103
+ if (foundBox) {
1104
+ archiveInfo = foundBox.userData.archiveInfo;
1105
+
1106
+ if (!archiveInfo || archiveInfo.nama_arsip?.startsWith("(Data Arsip Kosong")) {
1107
+ showMessage(`Boks ${foundBox.userData.id} kosong (tidak ada data arsip).`, true);
1108
+ const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`);
1109
+ if (listItem) {
1110
+ listItem.classList.remove('selected');
1111
+ }
1112
+ return;
1113
+ }
1114
+
1115
+ accessFrequency[locationId] = (accessFrequency[locationId] || 0) + 1;
1116
+
1117
+ const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`);
1118
+ if (listItem) {
1119
+ listItem.classList.add('selected');
1120
+ }
1121
+
1122
+ highlightedBox = foundBox;
1123
+ highlightedBox.userData.isBlinking = true;
1124
+ if (!highlightedBox.userData.originalMaterial) {
1125
+ highlightedBox.userData.originalMaterial = highlightedBox.material.clone();
1126
+ }
1127
+ highlightedBox.material = blinkMaterial;
1128
+
1129
+
1130
+ // --- Path Calculation using A* ---
1131
+ const pathStartY = -roomHeight / 2 + 0.1;
1132
+ const startWorld = new THREE.Vector3(0, pathStartY, roomDepth / 2 - 1); // Start slightly inside entrance
1133
+
1134
+ // Calculate target world position (in front of the shelf)
1135
+ const parentShelf = foundBox.parent;
1136
+ const shelfPosition = parentShelf.position;
1137
+ const shelfRotationY = parentShelf.rotation.y;
1138
+ // Target point in front of the shelf center, considering rotation
1139
+ const targetOffsetZ = shelfDepth / 2 + 0.5; // Distance in front
1140
+ const targetWorld = new THREE.Vector3(0, pathStartY, targetOffsetZ);
1141
+ targetWorld.applyEuler(parentShelf.rotation); // Apply shelf rotation
1142
+ targetWorld.add(shelfPosition); // Add shelf position
1143
+
1144
+ // Convert world coordinates to grid coordinates
1145
+ const startGrid = worldToGrid(startWorld.x, startWorld.z);
1146
+ const endGrid = worldToGrid(targetWorld.x, targetWorld.z);
1147
+
1148
+ // Ensure endGrid is walkable, if not, find nearest walkable neighbor (simple approach)
1149
+ if (navigationGrid[endGrid.z][endGrid.x] === 1) {
1150
+ console.warn("Target grid cell is blocked, finding nearest walkable...");
1151
+ const directions = [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]];
1152
+ let foundWalkable = false;
1153
+ for (const dir of directions) {
1154
+ const checkX = endGrid.x + dir[0];
1155
+ const checkZ = endGrid.z + dir[1];
1156
+ if (checkX >= 0 && checkX < gridWidth && checkZ >= 0 && checkZ < gridHeight && navigationGrid[checkZ][checkX] === 0) {
1157
+ endGrid.x = checkX;
1158
+ endGrid.z = checkZ;
1159
+ foundWalkable = true;
1160
+ break;
1161
+ }
1162
+ }
1163
+ if (!foundWalkable) {
1164
+ showMessage(`Tidak dapat menemukan titik tujuan yang bisa dijangkau dekat rak ${locationId}.`, true);
1165
+ return; // Stop if no walkable target found nearby
1166
+ }
1167
+ }
1168
+
1169
+
1170
+ console.log(`Pathfinding from grid [${startGrid.x}, ${startGrid.z}] to [${endGrid.x}, ${endGrid.z}]`);
1171
+ const gridPath = findPathAStar(startGrid.x, startGrid.z, endGrid.x, endGrid.z);
1172
+
1173
+ if (gridPath) {
1174
+ // Convert grid path back to world points
1175
+ const worldPathPoints = gridPath.map(node => gridToWorld(node.x, node.z));
1176
+
1177
+ // --- Record Aisle Access Frequency (based on path) ---
1178
+ const aisleZs = {};
1179
+ for(let i = 1; i < worldPathPoints.length - 1; i++) {
1180
+ const zRounded = worldPathPoints[i].z.toFixed(1);
1181
+ aisleZs[zRounded] = (aisleZs[zRounded] || 0) + 1;
1182
+ }
1183
+ let mostFrequentZ = null;
1184
+ let maxCount = 0;
1185
+ for (const z in aisleZs) {
1186
+ if (aisleZs[z] > maxCount) {
1187
+ maxCount = aisleZs[z];
1188
+ mostFrequentZ = z;
1189
+ }
1190
+ }
1191
+ if (mostFrequentZ) {
1192
+ const aisleKey = `Lorong Z=${mostFrequentZ}`;
1193
+ aisleAccessFrequency[aisleKey] = (aisleAccessFrequency[aisleKey] || 0) + 1;
1194
+ }
1195
+
1196
+
1197
+ // Draw the path line
1198
+ const pathGeometry = new THREE.BufferGeometry().setFromPoints(worldPathPoints);
1199
+ pathLine = new THREE.Line(pathGeometry, pathMaterial);
1200
+ pathLine.renderOrder = 1;
1201
+ scene.add(pathLine);
1202
+
1203
+ // --- Calculate and Update Average Retrieval Time ---
1204
+ let pathLength = 0;
1205
+ for (let i = 0; i < worldPathPoints.length - 1; i++) {
1206
+ pathLength += worldPathPoints[i].distanceTo(worldPathPoints[i + 1]);
1207
+ }
1208
+ const estimatedTime = (pathLength / walkingSpeed) + fixedPickTime;
1209
+ retrievalTimes.push(estimatedTime);
1210
+ if (retrievalTimes.length > maxRetrievalTimes) {
1211
+ retrievalTimes.shift();
1212
+ }
1213
+ const sum = retrievalTimes.reduce((a, b) => a + b, 0);
1214
+ const avgTime = sum / retrievalTimes.length;
1215
+ avgRetrievalTimeElement.textContent = avgTime.toFixed(1);
1216
+
1217
+ } else {
1218
+ // Fallback to simple line if A* fails
1219
+ console.error("A* pathfinding failed, drawing simple line as fallback.");
1220
+ const simplePathPoints = [startWorld, targetWorld];
1221
+ const pathGeometry = new THREE.BufferGeometry().setFromPoints(simplePathPoints);
1222
+ pathLine = new THREE.Line(pathGeometry, pathMaterial);
1223
+ pathLine.renderOrder = 1;
1224
+ scene.add(pathLine);
1225
+ avgRetrievalTimeElement.textContent = "N/A"; // Indicate failure
1226
+ }
1227
+
1228
+
1229
+ // --- Camera Focus (Orbit Mode) ---
1230
+ if (isOrbitView) {
1231
+ const midPoint = new THREE.Vector3().lerpVectors(startWorld, targetWorld, 0.5); // Midpoint of path start/end
1232
+ orbitControls.target.copy(midPoint);
1233
+ const targetPosition = endPoint.clone().add(new THREE.Vector3(3, 3, 3)); // Look from near the box
1234
+ const duration = 0.8;
1235
+ const startTime = clock.getElapsedTime();
1236
+ const startPosition = camera.position.clone();
1237
+ function moveCamera() {
1238
+ const elapsed = clock.getElapsedTime() - startTime;
1239
+ const t = Math.min(elapsed / duration, 1);
1240
+ camera.position.lerpVectors(startPosition, targetPosition, t * (2 - t));
1241
+ orbitControls.update();
1242
+ if (t < 1) requestAnimationFrame(moveCamera);
1243
+ }
1244
+ requestAnimationFrame(moveCamera);
1245
+ }
1246
+
1247
+ // --- Display Message ---
1248
+ let messageText = archiveInfo?.nama_arsip
1249
+ ? `Arsip "${archiveInfo.nama_arsip}" (ID: ${archiveInfo.id}) ditemukan di ${foundBox.userData.id}.`
1250
+ : `Boks ${foundBox.userData.id} ditemukan.`;
1251
+ if (archiveInfo?.deskripsi) {
1252
+ messageText += ` (${archiveInfo.deskripsi})`;
1253
+ }
1254
+ showMessage(messageText);
1255
+
1256
+ } else {
1257
+ // Handle case where the clicked location ID doesn't match any box
1258
+ showMessage(`Lokasi boks "${locationId}" tidak ditemukan di scene.`, true);
1259
+ const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`);
1260
+ if (listItem) {
1261
+ listItem.classList.remove('selected');
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ // Event listener for the archive list
1267
+ archiveListElement.addEventListener('click', (event) => {
1268
+ if (event.target && event.target.nodeName === 'LI') {
1269
+ const locationId = event.target.dataset.location;
1270
+ if (locationId) {
1271
+ findAndShowPath(locationId);
1272
+ }
1273
+ }
1274
+ });
1275
+
1276
+ // === Analytics Panel Logic ===
1277
+ const topAccessedListElement = document.getElementById('top-accessed-list');
1278
+ const topAislesListElement = document.getElementById('top-aisles-list');
1279
+ const updateAnalyticsBtn = document.getElementById('update-analytics-btn');
1280
+
1281
+ function updateAnalyticsPanel() {
1282
+ // --- Top Accessed Archives ---
1283
+ topAccessedListElement.innerHTML = ''; // Clear list
1284
+ const sortedAccess = Object.entries(accessFrequency)
1285
+ .sort(([, a], [, b]) => b - a) // Sort by count descending
1286
+ .slice(0, 10); // Get top 10
1287
+
1288
+ if (sortedAccess.length > 0) {
1289
+ sortedAccess.forEach(([locationId, count]) => {
1290
+ const archiveItem = archiveData.find(item => item.lokasi_rak === locationId);
1291
+ const displayName = archiveItem ? archiveItem.nama_arsip.substring(0, 20) + '...' : locationId; // Show name or ID
1292
+ const li = document.createElement('li');
1293
+ li.textContent = `${displayName}: `;
1294
+ const countSpan = document.createElement('span');
1295
+ countSpan.textContent = count;
1296
+ li.appendChild(countSpan);
1297
+ li.title = archiveItem ? `${archiveItem.nama_arsip} (${locationId}) - ${count} kali` : `${locationId} - ${count} kali`;
1298
+ topAccessedListElement.appendChild(li);
1299
+ });
1300
+ } else {
1301
+ topAccessedListElement.innerHTML = '<li>Belum ada data akses.</li>';
1302
+ }
1303
+
1304
+ // --- Top Used Aisles ---
1305
+ topAislesListElement.innerHTML = ''; // Clear list
1306
+ const sortedAisles = Object.entries(aisleAccessFrequency)
1307
+ .sort(([, a], [, b]) => b - a) // Sort by count descending
1308
+ .slice(0, 5); // Get top 5
1309
+
1310
+ if (sortedAisles.length > 0) {
1311
+ sortedAisles.forEach(([aisleKey, count]) => {
1312
+ const li = document.createElement('li');
1313
+ li.textContent = `${aisleKey}: `;
1314
+ const countSpan = document.createElement('span');
1315
+ countSpan.textContent = count;
1316
+ li.appendChild(countSpan);
1317
+ li.title = `${aisleKey} - ${count} kali`;
1318
+ topAislesListElement.appendChild(li);
1319
+ });
1320
+ } else {
1321
+ topAislesListElement.innerHTML = '<li>Belum ada data lorong.</li>';
1322
+ }
1323
+ }
1324
+
1325
+ // Add event listener to the button
1326
+ updateAnalyticsBtn.addEventListener('click', updateAnalyticsPanel);
1327
+
1328
+
1329
+ // === Animation Loop ===
1330
+ const clock = new THREE.Clock();
1331
+ const blinkRate = 0.5;
1332
+ let envUpdateInterval = 2; // Update environment info every 2 seconds
1333
+ let timeSinceLastEnvUpdate = 0;
1334
+ let heatmapUpdateInterval = 1; // Update heatmap every 1 second
1335
+ let timeSinceLastHeatmapUpdate = 0;
1336
+
1337
+ function animate() {
1338
+ requestAnimationFrame(animate);
1339
+ const delta = clock.getDelta();
1340
+ const elapsedTime = clock.getElapsedTime();
1341
+ timeSinceLastEnvUpdate += delta;
1342
+ timeSinceLastHeatmapUpdate += delta;
1343
+
1344
+ // --- Update Environment Info Periodically ---
1345
+ if (timeSinceLastEnvUpdate >= envUpdateInterval) {
1346
+ updateEnvironmentInfo();
1347
+ timeSinceLastEnvUpdate = 0; // Reset timer
1348
+ }
1349
+
1350
+ // --- Update Shelf Temperatures & Heatmap Periodically ---
1351
+ if (timeSinceLastHeatmapUpdate >= heatmapUpdateInterval) {
1352
+ updateShelfTemperatures();
1353
+ timeSinceLastHeatmapUpdate = 0; // Reset timer
1354
+ }
1355
+
1356
+
1357
+ // --- Blinking Logic ---
1358
+ if (highlightedBox && highlightedBox.userData.isBlinking) {
1359
+ const blinkPhase = (elapsedTime / blinkRate) % 1.0;
1360
+ if (blinkPhase < 0.5) {
1361
+ highlightedBox.material = blinkMaterial;
1362
+ } else {
1363
+ if(highlightedBox.userData.originalMaterial) {
1364
+ highlightedBox.material = highlightedBox.userData.originalMaterial;
1365
+ } else {
1366
+ highlightedBox.material = boxMaterial; // Fallback
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ // --- Control Updates ---
1372
+ if (isOrbitView) {
1373
+ orbitControls.update();
1374
+ // *** Raycasting for Hover Effect (only in Orbit view) ***
1375
+ handleHover();
1376
+ } else if (pointerLockControls.isLocked === true) {
1377
+ // FPV Movement Logic
1378
+ velocity.x -= velocity.x * 10.0 * delta;
1379
+ velocity.z -= velocity.z * 10.0 * delta;
1380
+ direction.z = Number(moveForward) - Number(moveBackward);
1381
+ direction.x = Number(moveRight) - Number(moveLeft);
1382
+ direction.normalize();
1383
+ const currentSpeed = moveSpeed * delta * 10;
1384
+ if (moveForward || moveBackward) velocity.z -= direction.z * currentSpeed;
1385
+ if (moveLeft || moveRight) velocity.x -= direction.x * currentSpeed;
1386
+
1387
+ pointerLockControls.moveRight(-velocity.x * delta);
1388
+ pointerLockControls.moveForward(-velocity.z * delta);
1389
+
1390
+ const camPos = pointerLockControls.getObject().position;
1391
+ const padding = 0.5;
1392
+ camPos.x = Math.max(-roomWidth / 2 + padding, Math.min(roomWidth / 2 - padding, camPos.x));
1393
+ camPos.z = Math.max(-roomDepth / 2 + padding, Math.min(roomDepth / 2 - padding, camPos.z));
1394
+ // *** Keep camera height constant ***
1395
+ camPos.y = (-roomHeight / 2) + playerEyeHeight;
1396
+ }
1397
+
1398
+ // --- Rendering ---
1399
+ renderer.render(scene, camera);
1400
+ if (isOrbitView) {
1401
+ labelRenderer.render(scene, camera);
1402
+ }
1403
+ }
1404
+
1405
+ // === Environment Info Simulation ===
1406
+ const tempValueElement = document.getElementById('temp-value');
1407
+ const humidityValueElement = document.getElementById('humidity-value');
1408
+ const lightValueElement = document.getElementById('light-value');
1409
+ const totalBoxesValueElement = document.getElementById('total-boxes-value');
1410
+ const utilityValueElement = document.getElementById('utility-value');
1411
+ const borrowedValueElement = document.getElementById('borrowed-value');
1412
+ // avgRetrievalTimeElement defined with other pathfinding vars
1413
+
1414
+ const baseTemp = 21; // Base temperature in °C
1415
+ const tempFluctuation = 1.5; // Max fluctuation +/-
1416
+ const baseHumidity = 55; // Base humidity in %
1417
+ const humidityFluctuation = 5; // Max fluctuation +/-
1418
+ const baseLight = 100; // Base light level in Lux
1419
+ const lightFluctuation = 25; // Max fluctuation +/-
1420
+ let simulatedBorrowedCount = 3; // Initial borrowed count
1421
+
1422
+ function updateEnvironmentInfo() {
1423
+ // Calculate average temperature from all shelves for display
1424
+ let totalTemp = 0;
1425
+ shelves.forEach(shelf => totalTemp += shelf.userData.temperature);
1426
+ const currentAvgTemp = shelves.length > 0 ? totalTemp / shelves.length : baseTemp;
1427
+ tempValueElement.textContent = currentAvgTemp.toFixed(1);
1428
+
1429
+ // Simulate humidity
1430
+ const currentHumidity = baseHumidity + (Math.random() * 2 - 1) * humidityFluctuation;
1431
+ humidityValueElement.textContent = Math.round(currentHumidity);
1432
+
1433
+ // Simulate light
1434
+ const currentLight = baseLight + (Math.random() * 2 - 1) * lightFluctuation;
1435
+ lightValueElement.textContent = Math.round(currentLight);
1436
+
1437
+ // Simulate borrowed count fluctuation
1438
+ if (Math.random() < 0.1) {
1439
+ simulatedBorrowedCount += (Math.random() < 0.5 ? -1 : 1);
1440
+ simulatedBorrowedCount = Math.max(0, Math.min(simulatedBorrowedCount, occupiedBoxesCount));
1441
+ }
1442
+ borrowedValueElement.textContent = simulatedBorrowedCount;
1443
+ }
1444
+
1445
+ // Function to update only storage-related info
1446
+ function updateStorageInfo() {
1447
+ totalBoxesValueElement.textContent = totalBoxesCreated;
1448
+ const utilityPercentage = totalBoxesCreated > 0 ? (occupiedBoxesCount / totalBoxesCreated) * 100 : 0;
1449
+ utilityValueElement.textContent = utilityPercentage.toFixed(1);
1450
+ simulatedBorrowedCount = Math.min(simulatedBorrowedCount, occupiedBoxesCount);
1451
+ borrowedValueElement.textContent = simulatedBorrowedCount;
1452
+ }
1453
+
1454
+ // === Heatmap Logic ===
1455
+ function getTemperatureColor(temp) {
1456
+ if (temp < TEMP_IDEAL_MIN) {
1457
+ // Blue range (colder = more blue)
1458
+ const factor = Math.max(0, (temp - TEMP_COLD) / (TEMP_IDEAL_MIN - TEMP_COLD)); // 0 at TEMP_COLD, 1 at TEMP_IDEAL_MIN
1459
+ return new THREE.Color().lerpColors(colorCold, colorIdeal, factor);
1460
+ } else if (temp > TEMP_IDEAL_MAX) {
1461
+ // Red range (hotter = more red)
1462
+ const factor = Math.min(1, (temp - TEMP_IDEAL_MAX) / (TEMP_HOT - TEMP_IDEAL_MAX)); // 0 at TEMP_IDEAL_MAX, 1 at TEMP_HOT
1463
+ return new THREE.Color().lerpColors(colorIdeal, colorHot, factor);
1464
+ } else {
1465
+ // Ideal range (green)
1466
+ return colorIdeal.clone();
1467
+ }
1468
+ }
1469
+
1470
+ function updateShelfTemperatures() {
1471
+ shelves.forEach(shelfGroup => {
1472
+ // Simulate temperature fluctuation based on whether it's anomalous
1473
+ if (shelfGroup.userData.isAnomalous) {
1474
+ // Keep temperature around its anomaly zone with small fluctuations
1475
+ const anomalyBase = (shelfGroup.userData.creationIndex === 5) ? TEMP_HOT : TEMP_COLD;
1476
+ shelfGroup.userData.temperature = anomalyBase + (Math.random() * 2 - 1); // Smaller fluctuation around anomaly
1477
+ } else {
1478
+ // Normal fluctuation around base temperature
1479
+ shelfGroup.userData.temperature += (Math.random() * 0.4 - 0.2); // Smaller fluctuation
1480
+ }
1481
+ // Clamp temperature within reasonable bounds (e.g., 10 to 35)
1482
+ shelfGroup.userData.temperature = Math.max(10, Math.min(35, shelfGroup.userData.temperature));
1483
+
1484
+ const tempColor = getTemperatureColor(shelfGroup.userData.temperature);
1485
+
1486
+ // Apply color to panels (only if not currently highlighted/blinking)
1487
+ // Note: Blinking logic in animate() will override this temporarily
1488
+ if (!highlightedBox || highlightedBox.parent !== shelfGroup) {
1489
+ shelfGroup.userData.panels.forEach(panel => {
1490
+ panel.material.color.copy(tempColor);
1491
+ });
1492
+ }
1493
+ });
1494
+ }
1495
+
1496
+ // === Hover Effect Logic ===
1497
+ function handleHover() {
1498
+ raycaster.setFromCamera(mouse, camera);
1499
+ // Intersect with the specific array of shelf panels
1500
+ const intersects = raycaster.intersectObjects(shelfPanels);
1501
+
1502
+ if (intersects.length > 0) {
1503
+ const intersectedPanel = intersects[0].object;
1504
+ // Traverse up to find the parent shelfGroup
1505
+ let parentShelf = intersectedPanel.parent;
1506
+ while (parentShelf && !(parentShelf instanceof THREE.Group && parentShelf.userData.panels)) {
1507
+ parentShelf = parentShelf.parent;
1508
+ }
1509
+
1510
+ if (parentShelf && parentShelf !== hoveredShelf) {
1511
+ hoveredShelf = parentShelf;
1512
+ const temp = hoveredShelf.userData.temperature;
1513
+ tempTooltipElement.textContent = `Suhu Rak: ${temp.toFixed(1)} °C`;
1514
+ tempTooltipElement.style.display = 'block';
1515
+ // Position tooltip near mouse - adjust offsets as needed
1516
+ tempTooltipElement.style.left = `${mouse.clientX + 15}px`;
1517
+ tempTooltipElement.style.top = `${mouse.clientY - 10}px`;
1518
+
1519
+ } else if (parentShelf === hoveredShelf) {
1520
+ // Update position if still hovering the same shelf
1521
+ tempTooltipElement.style.left = `${mouse.clientX + 15}px`;
1522
+ tempTooltipElement.style.top = `${mouse.clientY - 10}px`;
1523
+ }
1524
+
1525
+ } else {
1526
+ // No intersection
1527
+ if (hoveredShelf) {
1528
+ hoveredShelf = null;
1529
+ tempTooltipElement.style.display = 'none';
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // === Event Listeners ===
1535
+ // Mouse move listener for hover effect
1536
+ window.addEventListener('mousemove', (event) => {
1537
+ // calculate mouse position in normalized device coordinates
1538
+ // (-1 to +1) for both components
1539
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
1540
+ mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
1541
+ // Store clientX/Y for tooltip positioning
1542
+ mouse.clientX = event.clientX;
1543
+ mouse.clientY = event.clientY;
1544
+ }, false);
1545
+
1546
+
1547
+ // === Handle Window Resize ===
1548
+ window.addEventListener('resize', () => {
1549
+ camera.aspect = window.innerWidth / window.innerHeight;
1550
+ camera.updateProjectionMatrix();
1551
+ renderer.setSize(window.innerWidth, window.innerHeight);
1552
+ labelRenderer.setSize(window.innerWidth, window.innerHeight);
1553
+ }, false);
1554
+
1555
+
1556
+ // === Initial Setup ===
1557
+ window.onload = () => {
1558
+ // Assign the processed data (limited to 30) to the global archiveData
1559
+ archiveData = defaultDummyData;
1560
+ createSceneContent(); // Creates scene, builds grid, links data, populates list, updates storage info
1561
+ updateEnvironmentInfo(); // Initial environment display (temp, humidity, light, borrowed)
1562
+ updateShelfTemperatures(); // Initial heatmap coloring
1563
+ // Initialize average retrieval time display
1564
+ avgRetrievalTimeElement.textContent = "N/A";
1565
+ updateAnalyticsPanel(); // Initial display for analytics panel
1566
+ animate();
1567
+ }
1568
+
1569
+ </script>
1570
+ </body>
1571
+ </html>
index.html CHANGED
@@ -119,20 +119,28 @@
119
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
120
  gap: 1.5rem;
121
  }
 
 
 
 
 
 
 
 
 
 
122
  .link-card {
123
  background: rgba(13, 2, 33, 0.7);
124
  border: 1px solid var(--neon-purple);
125
  border-radius: 10px;
126
  padding: 1.5rem;
127
- transition: all 0.3s ease;
128
  position: relative;
129
  overflow: hidden;
130
  backdrop-filter: blur(5px);
 
131
  }
132
- .link-card:hover {
133
- transform: translateY(-5px) rotate(1deg);
134
- box-shadow: 0 5px 15px var(--neon-purple);
135
- border-color: var(--cosmic-blue);
136
  }
137
  .link-card::before {
138
  content: '';
@@ -145,8 +153,8 @@
145
  opacity: 0;
146
  transition: opacity 0.5s ease;
147
  }
148
- .link-card:hover::before {
149
- opacity: 0.1;
150
  }
151
  .link-title {
152
  font-size: 1.2rem;
@@ -164,20 +172,6 @@
164
  color: rgba(248, 248, 255, 0.7);
165
  margin-bottom: 1rem;
166
  }
167
- .link-url {
168
- display: inline-block;
169
- padding: 0.3rem 0.8rem;
170
- background: linear-gradient(90deg, var(--neon-purple), var(--cosmic-blue));
171
- color: white;
172
- text-decoration: none;
173
- border-radius: 20px;
174
- font-size: 0.8rem;
175
- transition: all 0.3s ease;
176
- }
177
- .link-url:hover {
178
- transform: scale(1.05);
179
- box-shadow: 0 0 10px var(--neon-purple);
180
- }
181
  footer {
182
  text-align: center;
183
  margin-top: 3rem;
@@ -260,51 +254,68 @@
260
  </header>
261
 
262
  <div class="links-container">
263
- <div class="link-card">
264
- <h3 class="link-title"><i class="fas fa-atom link-icon"></i> Physics Simulation</h3>
265
- <p class="link-description">Watch particles interact in a simulated environment.</p>
266
- <a href="#" class="link-url">Run Simulation</a>
267
- </div>
 
 
 
 
268
 
269
- <div class="link-card">
270
- <h3 class="link-title"><i class="fas fa-chart-line link-icon"></i> Economic Model</h3>
271
- <p class="link-description">Simulate market trends and economic principles.</p>
272
- <a href="#" class="link-url">View Model</a>
273
- </div>
 
 
274
 
275
- <div class="link-card">
276
- <h3 class="link-title"><i class="fas fa-users link-icon"></i> Crowd Simulation</h3>
277
- <p class="link-description">Observe simulated crowd behavior in different scenarios.</p>
278
- <a href="#" class="link-url">Start Simulation</a>
279
- </div>
 
 
280
 
281
- <div class="link-card">
282
- <h3 class="link-title"><i class="fas fa-leaf link-icon"></i> Ecosystem Simulation</h3>
283
- <p class="link-description">A model demonstrating predator-prey dynamics.</p>
284
- <a href="#" class="link-url">Explore Ecosystem</a>
285
- </div>
 
 
286
 
287
- <div class="link-card">
288
- <h3 class="link-title"><i class="fas fa-network-wired link-icon"></i> Network Traffic</h3>
289
- <p class="link-description">Simulate data flow and congestion in networks.</p>
290
- <a href="#" class="link-url">Analyze Traffic</a>
291
- </div>
 
 
292
 
293
- <div class="link-card">
294
- <h3 class="link-title"><i class="fas fa-car link-icon"></i> Traffic Simulation</h3>
295
- <p class="link-description">Model vehicle movement and traffic light systems.</p>
296
- <a href="#" class="link-url">Observe Traffic</a>
297
- </div>
 
 
 
298
 
299
- <div class="link-card">
300
- <h3 class="link-title"><i class="fas fa-brain link-icon"></i> Simulasi Arsitektur U-Net</h3>
301
- <p class="link-description">Visualisasi arsitektur U-Net yang umum digunakan untuk segmentasi gambar biomedis.</p>
302
- <a href="unet.html" class="link-url">Lihat Simulasi</a>
303
- </div>
 
 
304
  </div>
305
 
306
  <footer>
307
- <p>🌀 Created by jatnikonm · 2025 · Simulation Examples ⚙️</p>
308
  </footer>
309
  </div>
310
 
 
119
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
120
  gap: 1.5rem;
121
  }
122
+ .card-link {
123
+ text-decoration: none; /* Remove underline from link */
124
+ color: inherit; /* Inherit text color */
125
+ display: block; /* Make the link a block element */
126
+ transition: transform 0.3s ease, box-shadow 0.3s ease; /* Add transitions here */
127
+ }
128
+ .card-link:hover {
129
+ transform: translateY(-5px) rotate(1deg); /* Move hover effect here */
130
+ box-shadow: 0 5px 15px var(--neon-purple); /* Move hover effect here */
131
+ }
132
  .link-card {
133
  background: rgba(13, 2, 33, 0.7);
134
  border: 1px solid var(--neon-purple);
135
  border-radius: 10px;
136
  padding: 1.5rem;
 
137
  position: relative;
138
  overflow: hidden;
139
  backdrop-filter: blur(5px);
140
+ height: 100%; /* Ensure cards fill the link height */
141
  }
142
+ .card-link:hover .link-card { /* Apply border color change on link hover */
143
+ border-color: var(--cosmic-blue);
 
 
144
  }
145
  .link-card::before {
146
  content: '';
 
153
  opacity: 0;
154
  transition: opacity 0.5s ease;
155
  }
156
+ .card-link:hover .link-card::before {
157
+ opacity: 0.1;
158
  }
159
  .link-title {
160
  font-size: 1.2rem;
 
172
  color: rgba(248, 248, 255, 0.7);
173
  margin-bottom: 1rem;
174
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  footer {
176
  text-align: center;
177
  margin-top: 3rem;
 
254
  </header>
255
 
256
  <div class="links-container">
257
+ <!-- Wrap each card in an anchor tag -->
258
+
259
+ <a href="dt_preservasi.html" class="card-link"> <!-- Updated href -->
260
+ <div class="link-card">
261
+ <h3 class="link-title"><i class="fas fa-archive link-icon"></i> Digital Twin Preservasi Arsip</h3> <!-- Updated icon and title -->
262
+ <p class="link-description">Simulasi digital twin untuk manajemen dan preservasi arsip statis.</p> <!-- Updated description -->
263
+ <!-- Removed inner link -->
264
+ </div>
265
+ </a>
266
 
267
+ <a href="#" class="card-link">
268
+ <div class="link-card">
269
+ <h3 class="link-title"><i class="fas fa-chart-line link-icon"></i> Economic Model</h3>
270
+ <p class="link-description">Simulate market trends and economic principles.</p>
271
+ <!-- Removed inner link -->
272
+ </div>
273
+ </a>
274
 
275
+ <a href="blockchain.html" class="card-link"> <!-- Updated href -->
276
+ <div class="link-card">
277
+ <h3 class="link-title"><i class="fas fa-cubes link-icon"></i> Blockchain Simulation</h3> <!-- Updated icon and title -->
278
+ <p class="link-description">Visualisasi alur penyimpanan arsip menggunakan IPFS dan Blockchain.</p> <!-- Updated description -->
279
+ <!-- Removed inner link -->
280
+ </div>
281
+ </a>
282
 
283
+ <a href="#" class="card-link">
284
+ <div class="link-card">
285
+ <h3 class="link-title"><i class="fas fa-leaf link-icon"></i> Ecosystem Simulation</h3>
286
+ <p class="link-description">A model demonstrating predator-prey dynamics.</p>
287
+ <!-- Removed inner link -->
288
+ </div>
289
+ </a>
290
 
291
+ <a href="#" class="card-link">
292
+ <div class="link-card">
293
+ <h3 class="link-title"><i class="fas fa-network-wired link-icon"></i> Network Traffic</h3>
294
+ <p class="link-description">Simulate data flow and congestion in networks.</p>
295
+ <!-- Removed inner link -->
296
+ </div>
297
+ </a>
298
 
299
+ <a href="#" class="card-link">
300
+ <div class="link-card">
301
+ <h3 class="link-title"><i class="fas fa-car link-icon"></i> Traffic Simulation</h3>
302
+ <p class="link-description">Model vehicle movement and traffic light systems.</p>
303
+ <!-- Removed inner link -->
304
+ </div>
305
+ </a>
306
+
307
 
308
+ <a href="unet.html" class="card-link">
309
+ <div class="link-card">
310
+ <h3 class="link-title"><i class="fas fa-brain link-icon"></i> Simulasi Arsitektur U-Net</h3>
311
+ <p class="link-description">Visualisasi arsitektur U-Net yang umum digunakan untuk segmentasi gambar biomedis.</p>
312
+ <!-- Removed inner link -->
313
+ </div>
314
+ </a>
315
  </div>
316
 
317
  <footer>
318
+ <p>🌀 Created by jatnikonm · 2024 · Simulation Examples ⚙️</p>
319
  </footer>
320
  </div>
321
 
unet.html CHANGED
@@ -261,6 +261,17 @@
261
  font-size: 1rem; /* text-base */
262
  color: #cbd5e0; /* gray-400 */
263
  }
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  </style>
266
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -277,6 +288,7 @@
277
  <p><strong>Dimensi Data:</strong> <span id="data-dimensions">-</span></p>
278
  <p class="mt-2 text-xs">Gunakan mouse untuk memutar (klik kiri), zoom (scroll), dan pan (klik kanan).</p>
279
  <p class="mt-1 text-xs">Klik pada blok untuk info detail.</p>
 
280
  </div>
281
 
282
  <div id="controls">
 
261
  font-size: 1rem; /* text-base */
262
  color: #cbd5e0; /* gray-400 */
263
  }
264
+ #back-link {
265
+ display: block;
266
+ margin-top: 0.75rem; /* Add some space above */
267
+ color: #63b3ed; /* blue-400 */
268
+ text-decoration: none;
269
+ font-size: 0.8rem;
270
+ }
271
+ #back-link:hover {
272
+ color: #90cdf4; /* blue-300 */
273
+ text-decoration: underline;
274
+ }
275
 
276
  </style>
277
  <link rel="preconnect" href="https://fonts.googleapis.com">
 
288
  <p><strong>Dimensi Data:</strong> <span id="data-dimensions">-</span></p>
289
  <p class="mt-2 text-xs">Gunakan mouse untuk memutar (klik kiri), zoom (scroll), dan pan (klik kanan).</p>
290
  <p class="mt-1 text-xs">Klik pada blok untuk info detail.</p>
291
+ <a href="index.html" id="back-link">← Kembali ke Showcase</a> <!-- Added Back Link -->
292
  </div>
293
 
294
  <div id="controls">