Spaces:
Running
Running
jnm-itb
commited on
Commit
·
e07e0a7
1
Parent(s):
ceceae5
Implement structural updates and optimizations across multiple modules
Browse files- blockchain.html +414 -0
- dt_preservasi.html +1571 -0
- index.html +68 -57
- 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
|
133 |
-
|
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
|
149 |
-
|
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 |
-
|
264 |
-
|
265 |
-
|
266 |
-
<
|
267 |
-
|
|
|
|
|
|
|
|
|
268 |
|
269 |
-
<
|
270 |
-
<
|
271 |
-
|
272 |
-
|
273 |
-
|
|
|
|
|
274 |
|
275 |
-
<
|
276 |
-
<
|
277 |
-
|
278 |
-
|
279 |
-
|
|
|
|
|
280 |
|
281 |
-
<
|
282 |
-
<
|
283 |
-
|
284 |
-
|
285 |
-
|
|
|
|
|
286 |
|
287 |
-
<
|
288 |
-
<
|
289 |
-
|
290 |
-
|
291 |
-
|
|
|
|
|
292 |
|
293 |
-
<
|
294 |
-
<
|
295 |
-
|
296 |
-
|
297 |
-
|
|
|
|
|
|
|
298 |
|
299 |
-
<
|
300 |
-
<
|
301 |
-
|
302 |
-
|
303 |
-
|
|
|
|
|
304 |
</div>
|
305 |
|
306 |
<footer>
|
307 |
-
<p>🌀 Created by jatnikonm ·
|
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">
|