Spaces:
Running
Running
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Digital Twin Preservasi Arsip Statis</title> <style> | |
| body { margin: 0; font-family: 'Inter', sans-serif; background-color: #f0f0f0; color: #333; overflow: hidden; } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| /* Style for CSS2DRenderer overlay */ | |
| #label-container { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; /* Allow clicks to pass through to the canvas */ | |
| overflow: hidden; | |
| } | |
| .shelf-label { | |
| background-color: rgba(0, 0, 0, 0.6); | |
| color: white; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| font-size: 10px; /* Smaller font size for labels */ | |
| white-space: nowrap; | |
| /* pointer-events: none; Already handled by container */ | |
| } | |
| /* *** Style for Temperature Tooltip *** */ | |
| .temp-tooltip { | |
| position: absolute; /* Needed for CSS2DRenderer positioning */ | |
| background-color: rgba(255, 255, 153, 0.8); /* Light yellow */ | |
| color: black; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| white-space: nowrap; | |
| border: 1px solid #ccc; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); | |
| display: none; /* Hidden by default */ | |
| pointer-events: none; /* Ignore mouse events */ | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: auto; | |
| max-width: 80%; | |
| text-align: center; | |
| z-index: 100; | |
| display: block; | |
| background-color: rgba(255, 255, 255, 0.85); | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| font-size: 1.1em; | |
| } | |
| #controls-container { | |
| position: absolute; | |
| bottom: 15px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(0, 0, 0, 0.7); | |
| padding: 12px 20px; | |
| border-radius: 10px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| align-items: center; | |
| justify-content: center; | |
| max-width: 95%; | |
| z-index: 100; | |
| } | |
| #controls-container label, | |
| #controls-container input, | |
| #controls-container button { | |
| color: white; | |
| font-size: 0.95em; | |
| } | |
| #controls-container button, | |
| #controls-container input[type="file"]::file-selector-button /* Keep style for potential re-add */ | |
| { | |
| padding: 8px 14px; | |
| border-radius: 6px; | |
| border: none; | |
| background-color: #555; | |
| cursor: pointer; | |
| transition: background-color 0.3s, box-shadow 0.3s; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.3); | |
| } | |
| #controls-container button:hover, | |
| #controls-container input[type="file"]::file-selector-button:hover { | |
| background-color: #777; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.4); | |
| } | |
| #controls-container button:active, | |
| #controls-container input[type="file"]::file-selector-button:active { | |
| background-color: #444; | |
| box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); | |
| } | |
| #controls-container input[type="file"] { | |
| background: none; | |
| border: none; | |
| padding: 0; | |
| box-shadow: none; | |
| max-width: 150px; | |
| } | |
| /* #instructions style removed */ | |
| #archive-list-container { | |
| position: absolute; | |
| top: 60px; /* Adjusted position now that instructions are gone */ | |
| left: 10px; | |
| width: 210px; | |
| max-height: calc(100vh - 80px - 80px); /* Adjusted height */ | |
| background-color: rgba(0, 0, 0, 0.75); | |
| color: white; | |
| padding: 10px; | |
| border-radius: 8px; | |
| font-size: 0.9em; | |
| overflow-y: auto; | |
| z-index: 99; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.3); | |
| } | |
| #archive-list-container h3 { | |
| margin-top: 0; | |
| margin-bottom: 10px; | |
| font-size: 1em; | |
| border-bottom: 1px solid #666; | |
| padding-bottom: 5px; | |
| } | |
| #archive-list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| #archive-list li { | |
| padding: 6px 8px; | |
| cursor: pointer; | |
| border-bottom: 1px solid #444; | |
| transition: background-color 0.2s; | |
| font-size: 0.9em; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| #archive-list li:last-child { border-bottom: none; } | |
| #archive-list li:hover { background-color: rgba(255, 255, 255, 0.2); } | |
| #archive-list li.selected { background-color: rgba(100, 149, 237, 0.5); font-weight: bold; } | |
| #message-area { | |
| position: absolute; | |
| bottom: 80px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(34, 139, 34, 0.8); | |
| color: white; | |
| padding: 8px 15px; | |
| border-radius: 5px; | |
| z-index: 101; | |
| display: none; | |
| font-size: 0.9em; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| text-align: center; | |
| } | |
| /* Style for Environment Info Panel */ | |
| #environment-info { | |
| position: absolute; | |
| top: 60px; /* Position below main title */ | |
| right: 10px; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| font-size: 0.9em; | |
| z-index: 99; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.3); | |
| min-width: 200px; /* Slightly wider */ | |
| } | |
| #environment-info h4 { | |
| margin-top: 0; | |
| margin-bottom: 8px; | |
| font-size: 1em; | |
| border-bottom: 1px solid #666; | |
| padding-bottom: 4px; | |
| } | |
| #environment-info p { | |
| margin: 5px 0; /* Adjusted margin */ | |
| font-size: 0.95em; | |
| display: flex; /* Use flex for alignment */ | |
| justify-content: space-between; /* Space out label and value */ | |
| } | |
| #environment-info span { | |
| font-weight: bold; | |
| text-align: right; /* Align value to the right */ | |
| margin-left: 10px; /* Add space between label and value */ | |
| } | |
| /* Style for Analytics Panel */ | |
| #analytics-panel { | |
| position: absolute; | |
| top: 300px; /* *** ADJUSTED TOP POSITION *** Position further down */ | |
| right: 10px; | |
| background-color: rgba(0, 0, 0, 0.75); /* Slightly darker */ | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| font-size: 0.9em; | |
| z-index: 98; /* Below env info if overlapping */ | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.3); | |
| min-width: 200px; | |
| max-height: calc(100vh - 320px - 80px); /* *** ADJUSTED MAX HEIGHT *** Limit height based on new top */ | |
| overflow-y: auto; /* Add scroll if content overflows */ | |
| } | |
| #analytics-panel h4 { | |
| margin-top: 0; | |
| margin-bottom: 8px; | |
| font-size: 1em; | |
| border-bottom: 1px solid #666; | |
| padding-bottom: 4px; | |
| } | |
| #analytics-panel h5 { | |
| margin-top: 10px; | |
| margin-bottom: 5px; | |
| font-size: 0.95em; | |
| font-weight: bold; | |
| } | |
| #analytics-panel ul { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0 0 10px 0; | |
| font-size: 0.9em; | |
| } | |
| #analytics-panel li { | |
| padding: 3px 0; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| #analytics-panel li span { /* Style for count */ | |
| font-weight: bold; | |
| margin-left: 8px; | |
| float: right; /* Align count to the right */ | |
| } | |
| #analytics-panel button { /* Style for update button */ | |
| display: block; | |
| width: 100%; | |
| margin-top: 10px; | |
| padding: 5px 10px; | |
| border-radius: 4px; | |
| border: none; | |
| background-color: #555; | |
| color: white; | |
| cursor: pointer; | |
| transition: background-color 0.3s; | |
| } | |
| #analytics-panel button:hover { | |
| background-color: #777; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info">Digital Twin Preservasi Arsip Statis</div> <div id="message-area">Pesan sukses/error</div> | |
| <div id="archive-list-container"> | |
| <h3>Daftar Arsip (30)</h3> | |
| <ul id="archive-list"> | |
| <li>Memuat data...</li> | |
| </ul> | |
| </div> | |
| <div id="environment-info"> | |
| <h4>Info Lingkungan & Status</h4> | |
| <p>Suhu Rata²: <span id="temp-value">--</span> °C</p> | |
| <p>Kelembapan: <span id="humidity-value">--</span> %</p> | |
| <p>Cahaya: <span id="light-value">--</span> Lux</p> | |
| <hr style="border-color: #555; margin: 8px 0;"> | |
| <p>Total Boks: <span id="total-boxes-value">--</span></p> | |
| <p>Utilitas: <span id="utility-value">--</span> %</p> | |
| <p>Arsip Keluar: <span id="borrowed-value">--</span></p> | |
| <p>Rata² Temu: <span id="avg-retrieval-time">--</span> detik</p> | |
| </div> | |
| <div id="analytics-panel"> | |
| <h4>Analisis Akses</h4> | |
| <button id="update-analytics-btn">Tampilkan/Perbarui Analisis</button> | |
| <h5>Arsip Paling Sering Diakses:</h5> | |
| <ul id="top-accessed-list"> | |
| <li>Belum ada data.</li> | |
| </ul> | |
| <h5>Lorong Paling Sering Dilalui:</h5> | |
| <ul id="top-aisles-list"> | |
| <li>Belum ada data.</li> | |
| </ul> | |
| </div> | |
| <div id="controls-container"> | |
| <button id="toggle-view-button">Ganti ke Mode Jalan</button> | |
| </div> | |
| <div id="label-container"></div> | |
| <div id="temp-tooltip" class="temp-tooltip"></div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/PointerLockControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/renderers/CSS2DRenderer.js"></script> | |
| <script> | |
| // === Basic Scene Setup === | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| document.body.appendChild(renderer.domElement); | |
| // === CSS2D Renderer for Labels === | |
| const labelRenderer = new THREE.CSS2DRenderer(); | |
| labelRenderer.setSize(window.innerWidth, window.innerHeight); | |
| labelRenderer.domElement.style.position = 'absolute'; | |
| labelRenderer.domElement.style.top = '0px'; | |
| labelRenderer.domElement.id = 'label-container'; | |
| document.body.appendChild(labelRenderer.domElement); | |
| // === Message Area === | |
| const messageArea = document.getElementById('message-area'); | |
| function showMessage(text, isError = false, duration = 5000) { /* ... (showMessage function remains the same) ... */ | |
| messageArea.textContent = text; | |
| messageArea.style.backgroundColor = isError ? 'rgba(220, 20, 60, 0.8)' : 'rgba(34, 139, 34, 0.8)'; | |
| messageArea.style.display = 'block'; | |
| setTimeout(() => { | |
| messageArea.style.display = 'none'; | |
| }, duration); | |
| } | |
| // === Lighting === | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(10, 20, 5); | |
| directionalLight.castShadow = true; | |
| scene.add(directionalLight); | |
| directionalLight.shadow.mapSize.width = 1024; | |
| directionalLight.shadow.mapSize.height = 1024; | |
| directionalLight.shadow.camera.near = 0.5; | |
| directionalLight.shadow.camera.far = 50; | |
| // === Room === | |
| const roomWidth = 20; | |
| const roomHeight = 5; | |
| const roomDepth = 30; | |
| const floorGeometry = new THREE.PlaneGeometry(roomWidth, roomDepth); | |
| const floorMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc, side: THREE.DoubleSide }); | |
| const floor = new THREE.Mesh(floorGeometry, floorMaterial); | |
| floor.rotation.x = -Math.PI / 2; | |
| floor.position.y = -roomHeight / 2; | |
| floor.receiveShadow = true; | |
| scene.add(floor); | |
| // === Constants === | |
| const playerEyeHeight = 1.7; | |
| const MAX_ARCHIVES_TO_LOAD = 30; // Limit number of archives | |
| const TEMP_IDEAL_MIN = 18; | |
| const TEMP_IDEAL_MAX = 24; | |
| const TEMP_COLD = 15; | |
| const TEMP_HOT = 27; | |
| const ANOMALY_SHELF_INDICES = [5, 15]; // *** Indices of shelves with anomalies *** | |
| const GRID_RESOLUTION = 0.5; // Size of each grid cell for pathfinding | |
| // === Global Data Storage === | |
| let archiveData = []; // Use let to allow reassignment | |
| const boxes = []; | |
| const shelves = []; | |
| const shelfLabels = []; | |
| const rails = []; | |
| let totalBoxesCreated = 0; // Track total boxes physically created | |
| let occupiedBoxesCount = 0; // Track boxes with actual data | |
| const retrievalTimes = []; // Array to store recent retrieval times | |
| const maxRetrievalTimes = 5; // Number of times to average over | |
| const walkingSpeed = 1.5; // Units per second (adjust as needed) | |
| const fixedPickTime = 5; // Fixed seconds for picking the box (adjust as needed) | |
| const accessFrequency = {}; // { locationId: count } | |
| const aisleAccessFrequency = {}; // { aisleZ: count } | |
| const shelfPanels = []; // *** Array to store all shelf panels for raycasting *** | |
| let navigationGrid = []; // *** Grid for A* pathfinding *** | |
| let gridWidth = 0; | |
| let gridHeight = 0; | |
| let gridOriginOffset = { x: 0, z: 0 }; | |
| // === Raycasting & Hover === | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| let hoveredShelf = null; | |
| const tempTooltipElement = document.getElementById('temp-tooltip'); // Get tooltip div | |
| // === Default Dummy Data (Updated & Mapped) === | |
| // Function to generate box IDs sequentially | |
| function generateBoxIds(rows, shelvesPerRow, levels, boxesPerLevel) { | |
| const ids = []; | |
| for (let r = 1; r <= rows; r++) { | |
| for (let s = 1; s <= shelvesPerRow; s++) { | |
| for (let i = 1; i <= levels; i++) { | |
| for (let b = 1; b <= boxesPerLevel; b++) { | |
| ids.push(`B${r}-R${s}-S${i}-N${b}`); | |
| } | |
| } | |
| } | |
| } | |
| return ids; | |
| } | |
| // Raw data extracted from PDF (first 30 entries) | |
| const rawPdfData = [ | |
| { no: 1, sub: "Agenda", uraian: "Mijnwezen Agenda A Directie 1902. Nummer 56-28271.", kurun: "1 Januari 1902-14 Oktober 1902", kondisi: "Licht beschadigd" }, | |
| { 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" }, | |
| { no: 3, sub: "Agenda", uraian: "Agenda V 1930. Doorlopend nummer 8191-10530. (2590).", kurun: "6 Mei 1930-13 Juni 1930", kondisi: "Goed" }, | |
| { no: 4, sub: "Agenda", uraian: "Agenda 1934. Doorloppend nummer 1-185.", kurun: "15 Januari 1934-29 Desember 1934", kondisi: "Licht beschadigd" }, | |
| { no: 5, sub: "Agenda", uraian: "Agenda A 1935. Doorloppend nummer 1-204. (2595).", kurun: "3 Januari 1935-23 Desember 1935", kondisi: "Goed" }, | |
| { 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" }, | |
| { 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" }, | |
| { no: 8, sub: "Agenda", uraian: "Agenda A 1938. Doorloopend nummer 1-503. (2598).", kurun: "2 Januari 1938-31 Desember 1938", kondisi: "Zwaar beschadigd, fungus" }, | |
| { no: 9, sub: "Agenda", uraian: "Agenda A 1939. Doorloopend nummer 1-486.", kurun: "2 Januari 1939-28 Desember 1939", kondisi: "Zwaar beschadigd, fungus" }, | |
| { no: 10, sub: "Agenda", uraian: "Agenda A 1940. Doorloopend nummer 1-414. (2600).", kurun: "2 Januari 1940-31 Desember 1940", kondisi: "Zwaar beschadigd, fungus" }, | |
| { 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" }, | |
| { no: 12, sub: "Agendaboek", uraian: "Inkomende (Agenda Boek) 1950, Nummer 1-1972.", kurun: "28 Januari 1950 - 16 November 1950", kondisi: "Goed" }, | |
| { 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" }, | |
| { no: 14, sub: "Bundel Tecnish Economische", uraian: "Bundel Technish Economische Afdeeling Alfabetis A-Z.", kurun: "1923-1923", kondisi: "Licht beschadigd" }, | |
| { 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" }, | |
| { 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" }, | |
| { no: 17, sub: "Controle Agenda", uraian: "Controle Agenda M 1928. VI-M 4. No. 1-22110.", kurun: "1928-1928", kondisi: "Licht beschadigd, fungus" }, | |
| { 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" }, | |
| { no: 19, sub: "Controle Agenda", uraian: "Controle Agenda 1931. VI.M 6, nummer 1-16654.", kurun: "1931-1931", kondisi: "Zwaar beschadigd, fungus" }, | |
| { 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" }, | |
| { no: 21, sub: "Controle Verbalen", uraian: "Controle Verbalen 1927, Nummer 1-11780", kurun: "1927-1927", kondisi: "Licht beschadigd" }, | |
| { 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" }, | |
| { no: 23, sub: "Geheim Agenda", uraian: "Agende Geheim en verbalen 1903-1919.", kurun: "28 Februari 1903-9 Mei 1919", kondisi: "Goed" }, | |
| { 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" }, | |
| { 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" }, | |
| { 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" }, | |
| { no: 27, sub: "Geheime Directie-Verbalen", uraian: "Geheime Directie-Verbalen 1929 t/m 1942.", kurun: "2 Desember 1929-13 Februari 1942", kondisi: "Mieren" }, | |
| { no: 28, sub: "Index", uraian: "Index Besluiten Registen Opsporingsdienst 3, Alfabetis A-W", kurun: "TT", kondisi: "Goed" }, | |
| { no: 29, sub: "Index", uraian: "Index Controle 1919 Deel VI-M.3 Alfabetis A-W", kurun: "1919-1919", kondisi: "Licht beschadigd, fungus" }, | |
| { 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" } | |
| ]; | |
| // Function to clean and format raw data, now assigning varied locations | |
| function processRawData(rawData, boxIds) { | |
| const processedData = []; | |
| const count = Math.min(rawData.length, MAX_ARCHIVES_TO_LOAD); // Limit to 30 | |
| // Define specific, varied box locations for the 30 archives | |
| const variedLocations = [ | |
| 'B1-R1-S1-N1', 'B1-R1-S3-N3', 'B1-R1-S5-N2', | |
| 'B1-R3-S2-N4', 'B1-R3-S4-N1', 'B1-R5-S1-N3', | |
| 'B1-R5-S3-N1', 'B2-R2-S2-N2', 'B2-R2-S4-N4', | |
| 'B2-R2-S5-N1', 'B2-R4-S1-N1', 'B2-R4-S3-N3', | |
| 'B2-R6-S2-N1', 'B2-R6-S5-N4', 'B3-R1-S1-N4', | |
| 'B3-R1-S4-N2', 'B3-R3-S3-N1', 'B3-R3-S5-N3', | |
| 'B3-R5-S2-N2', 'B3-R5-S4-N4', 'B4-R2-S1-N3', | |
| 'B4-R2-S3-N1', 'B4-R2-S5-N4', 'B4-R4-S2-N1', | |
| 'B4-R4-S4-N3', 'B4-R6-S1-N2', 'B4-R6-S3-N4', | |
| 'B4-R6-S5-N1', 'B1-R2-S1-N1', 'B2-R3-S1-N2' | |
| ]; | |
| for (let index = 0; index < count; index++) { | |
| const item = rawData[index]; | |
| const nama_arsip = item.uraian?.replace(/\n/g, ' ').replace(/"/g, '').trim() || `Arsip Tidak Bernama ${index + 1}`; | |
| const deskripsi = item.sub?.replace(/\n/g, ' ').replace(/"/g, '').trim() || "Tidak Ada Deskripsi"; | |
| const kondisi = item.kondisi?.replace(/\n/g, ' ').replace(/"/g, '').trim() || "Tidak Diketahui"; | |
| let tahun = "TT"; | |
| if (item.kurun && typeof item.kurun === 'string') { | |
| const yearMatch = item.kurun.match(/\d{4}/g); | |
| if (yearMatch) { | |
| if (yearMatch.length > 1) { | |
| const startYear = parseInt(yearMatch[0]); | |
| const endYear = parseInt(yearMatch[yearMatch.length - 1]); | |
| tahun = (startYear <= endYear) ? `${startYear}-${endYear}` : `${endYear}-${startYear}`; | |
| } else { | |
| tahun = yearMatch[0]; | |
| } | |
| } else if (item.kurun.trim().toUpperCase() !== 'TT') { | |
| tahun = item.kurun.trim(); | |
| } | |
| } | |
| processedData.push({ | |
| id: `ARSIP-${String(index + 1).padStart(3, '0')}`, | |
| nama_arsip: nama_arsip, | |
| deskripsi: deskripsi, | |
| tahun: tahun, | |
| kategori: deskripsi, | |
| lokasi_rak: variedLocations[index], // Assign varied location | |
| kondisi: kondisi, | |
| format: "Kertas" | |
| }); | |
| } | |
| return processedData; | |
| } | |
| // === Shelves and Boxes (Roll O Pack Style - Facing Each Other) === | |
| const boxGeometry = new THREE.BoxGeometry(0.4, 0.3, 0.5); | |
| const boxMaterial = new THREE.MeshStandardMaterial({ color: 0xdeb887 }); | |
| const shelfWidth = 2; | |
| const shelfDepth = 0.6; | |
| const shelfHeight = 2; | |
| const shelfLevels = 5; | |
| const shelfBoardThickness = 0.05; | |
| const panelThickness = 0.05; | |
| const shelfColor = 0xAAAAAA; | |
| const panelColor = 0x999999; | |
| const handleColor = 0x555555; | |
| const railColor = 0x777777; | |
| const numShelfRows = 4; | |
| const shelvesPerDoubleRow = 6; | |
| const rowSpacing = 4; | |
| const pairSpacingZ = 2.0; | |
| const aisleWidthFacing = 1.8; | |
| const railHeight = 0.02; | |
| const railWidth = 0.04; | |
| const railSpacing = 0.4; | |
| const boxesPerLevel = 4; | |
| const numPairsPerRow = shelvesPerDoubleRow / 2; | |
| const startZ = - (numPairsPerRow - 1) * pairSpacingZ / 2; | |
| const startX = - (numShelfRows - 1) * rowSpacing / 2; | |
| // Generate all possible box IDs based on the layout | |
| const allBoxIds = generateBoxIds(numShelfRows, shelvesPerDoubleRow, shelfLevels, boxesPerLevel); | |
| // Process the raw data (first 30 entries) and map it to the varied box IDs | |
| let defaultDummyData = processRawData(rawPdfData, allBoxIds); // Use let | |
| // --- Heatmap Colors --- | |
| const colorCold = new THREE.Color(0x0000ff); // Blue | |
| const colorIdeal = new THREE.Color(0x00ff00); // Green | |
| const colorHot = new THREE.Color(0xff0000); // Red | |
| function createSceneContent() { | |
| // Clear existing objects | |
| shelves.forEach(shelf => scene.remove(shelf)); | |
| boxes.forEach(box => { if (box.parent) box.parent.remove(box); }); | |
| shelfLabels.forEach(label => { label.element.remove(); if(label.parent) label.parent.remove(label); }); | |
| rails.forEach(rail => scene.remove(rail)); | |
| shelfPanels.length = 0; // *** Clear shelf panels array *** | |
| shelves.length = 0; | |
| boxes.length = 0; | |
| shelfLabels.length = 0; | |
| rails.length = 0; | |
| let boxCreationCounter = 0; | |
| let shelfCreationIndex = 0; | |
| const shelfMaterial = new THREE.MeshStandardMaterial({ color: shelfColor }); | |
| const panelMaterial = new THREE.MeshStandardMaterial({ color: panelColor, vertexColors: false }); | |
| const handleMaterial = new THREE.MeshStandardMaterial({ color: handleColor }); | |
| const railMaterial = new THREE.MeshStandardMaterial({ color: railColor }); | |
| // --- Create Rails (Two per shelf) --- | |
| const singleRailLength = shelfWidth; | |
| const railGeometry = new THREE.BoxGeometry(singleRailLength, railHeight, railWidth); | |
| for (let r = 0; r < numShelfRows; r++) { | |
| const railX = startX + r * rowSpacing; | |
| const railY = floor.position.y + railHeight / 2 + 0.001; | |
| for (let p = 0; p < numPairsPerRow; p++) { | |
| const pairCenterZ = startZ + p * pairSpacingZ; | |
| // Rails for Left Shelf | |
| const leftShelfCenterZ = pairCenterZ - aisleWidthFacing / 2; | |
| const railLeftFront = new THREE.Mesh(railGeometry, railMaterial); | |
| railLeftFront.position.set(railX, railY, leftShelfCenterZ + railSpacing / 2); | |
| railLeftFront.receiveShadow = true; | |
| scene.add(railLeftFront); | |
| rails.push(railLeftFront); | |
| const railLeftBack = new THREE.Mesh(railGeometry, railMaterial); | |
| railLeftBack.position.set(railX, railY, leftShelfCenterZ - railSpacing / 2); | |
| railLeftBack.receiveShadow = true; | |
| scene.add(railLeftBack); | |
| rails.push(railLeftBack); | |
| // Rails for Right Shelf | |
| const rightShelfCenterZ = pairCenterZ + aisleWidthFacing / 2; | |
| const railRightFront = new THREE.Mesh(railGeometry, railMaterial); | |
| railRightFront.position.set(railX, railY, rightShelfCenterZ + railSpacing / 2); | |
| railRightFront.receiveShadow = true; | |
| scene.add(railRightFront); | |
| rails.push(railRightFront); | |
| const railRightBack = new THREE.Mesh(railGeometry, railMaterial); | |
| railRightBack.position.set(railX, railY, rightShelfCenterZ - railSpacing / 2); | |
| railRightBack.receiveShadow = true; | |
| scene.add(railRightBack); | |
| rails.push(railRightBack); | |
| } | |
| } | |
| // --- Create Shelves --- | |
| for (let r = 0; r < numShelfRows; r++) { | |
| for (let s = 0; s < shelvesPerDoubleRow; s++) { | |
| const shelfGroup = new THREE.Group(); | |
| shelfGroup.userData.creationIndex = shelfCreationIndex++; | |
| const shelfX = startX + r * rowSpacing; | |
| const pairIndex = Math.floor(s / 2); | |
| const isRightShelf = s % 2 !== 0; | |
| const currentPairCenterZ = startZ + pairIndex * pairSpacingZ; | |
| let shelfZ; | |
| let rotationY = 0; | |
| if (isRightShelf) { | |
| shelfZ = currentPairCenterZ + aisleWidthFacing / 2; | |
| rotationY = Math.PI; | |
| } else { | |
| shelfZ = currentPairCenterZ - aisleWidthFacing / 2; | |
| rotationY = 0; | |
| } | |
| // --- Roll O Pack Structure --- | |
| const sidePanelLeftMat = panelMaterial.clone(); | |
| const sidePanelRightMat = panelMaterial.clone(); | |
| const backPanelMat = panelMaterial.clone(); | |
| const topPanelMat = panelMaterial.clone(); | |
| const sidePanelLeftGeo = new THREE.BoxGeometry(panelThickness, shelfHeight, shelfDepth); | |
| const sidePanelLeft = new THREE.Mesh(sidePanelLeftGeo, sidePanelLeftMat); | |
| sidePanelLeft.position.set(-shelfWidth / 2 + panelThickness / 2, 0, 0); | |
| sidePanelLeft.castShadow = true; sidePanelLeft.receiveShadow = true; | |
| shelfGroup.add(sidePanelLeft); | |
| shelfPanels.push(sidePanelLeft); // *** Add panel to array for raycasting *** | |
| const sidePanelRightGeo = new THREE.BoxGeometry(panelThickness, shelfHeight, shelfDepth); | |
| const sidePanelRight = new THREE.Mesh(sidePanelRightGeo, sidePanelRightMat); | |
| sidePanelRight.position.set(shelfWidth / 2 - panelThickness / 2, 0, 0); | |
| sidePanelRight.castShadow = true; sidePanelRight.receiveShadow = true; | |
| shelfGroup.add(sidePanelRight); | |
| shelfPanels.push(sidePanelRight); // *** Add panel to array for raycasting *** | |
| const backPanelGeo = new THREE.BoxGeometry(shelfWidth - 2 * panelThickness, shelfHeight, panelThickness); | |
| const backPanel = new THREE.Mesh(backPanelGeo, backPanelMat); | |
| backPanel.position.set(0, 0, -shelfDepth / 2 + panelThickness / 2); | |
| backPanel.castShadow = true; backPanel.receiveShadow = true; | |
| shelfGroup.add(backPanel); | |
| shelfPanels.push(backPanel); // *** Add panel to array for raycasting *** | |
| const topPanelGeo = new THREE.BoxGeometry(shelfWidth - 2* panelThickness, panelThickness, shelfDepth); | |
| const topPanel = new THREE.Mesh(topPanelGeo, topPanelMat); | |
| topPanel.position.set(0, shelfHeight / 2 - panelThickness / 2, 0); | |
| topPanel.castShadow = true; topPanel.receiveShadow = true; | |
| shelfGroup.add(topPanel); | |
| shelfPanels.push(topPanel); // *** Add panel to array for raycasting *** | |
| // Store references to the panel meshes in the group's userData for easy access | |
| shelfGroup.userData.panels = [sidePanelLeft, sidePanelRight, backPanel, topPanel]; | |
| shelfGroup.userData.originalPanelMaterial = panelMaterial; // Store the base material | |
| // *** Assign initial temperature based on anomaly list *** | |
| if (ANOMALY_SHELF_INDICES.includes(shelfGroup.userData.creationIndex)) { | |
| shelfGroup.userData.temperature = (shelfGroup.userData.creationIndex === 5) | |
| ? TEMP_HOT - Math.random() * 3 // Hotter anomaly | |
| : TEMP_COLD + Math.random() * 3; // Colder anomaly | |
| shelfGroup.userData.isAnomalous = true; | |
| } else { | |
| shelfGroup.userData.temperature = baseTemp + (Math.random() * 4 - 2); // Normal initial temp | |
| shelfGroup.userData.isAnomalous = false; | |
| } | |
| // --- Add Rotating Handle --- | |
| const handleRadius = 0.08; | |
| const handleHeight = 0.05; | |
| const handleSegments = 16; | |
| const handleGeometry = new THREE.CylinderGeometry(handleRadius, handleRadius, handleHeight, handleSegments); | |
| const handle = new THREE.Mesh(handleGeometry, handleMaterial); | |
| const handleX = isRightShelf | |
| ? (shelfWidth / 2 - panelThickness / 2 - handleHeight / 2) | |
| : (-shelfWidth / 2 + panelThickness / 2 + handleHeight / 2); | |
| const handleY = 0; | |
| const handleZ = shelfDepth / 2 - handleRadius * 1.5; | |
| handle.position.set(handleX, handleY, handleZ); | |
| handle.rotation.z = Math.PI / 2; | |
| handle.castShadow = true; | |
| shelfGroup.add(handle); | |
| // --- Shelf Boards and Boxes --- | |
| const boardWidth = shelfWidth - 2 * panelThickness; | |
| const boardGeometry = new THREE.BoxGeometry(boardWidth, shelfBoardThickness, shelfDepth - panelThickness); | |
| for (let i = 0; i < shelfLevels; i++) { | |
| const boardY = -shelfHeight / 2 + shelfBoardThickness / 2 + i * (shelfHeight / (shelfLevels)); // Adjusted spacing slightly | |
| const board = new THREE.Mesh(boardGeometry, shelfMaterial.clone()); // Clone shelf material too if needed | |
| board.position.set(0, boardY, 0); | |
| board.castShadow = true; board.receiveShadow = true; | |
| shelfGroup.add(board); | |
| const boxStartX = -boardWidth / 2 + 0.25; | |
| const boxSpacingX = 0.5; | |
| for (let b = 0; b < boxesPerLevel; b++) { | |
| // Use the pre-generated ID based on the overall counter | |
| const boxId = allBoxIds[boxCreationCounter]; | |
| // Only create a box if an ID exists for it | |
| if (!boxId) break; | |
| if (boxStartX + b * boxSpacingX + 0.4 / 2 > boardWidth / 2) continue; | |
| const box = new THREE.Mesh(boxGeometry, boxMaterial.clone()); | |
| const boxX = boxStartX + b * boxSpacingX; | |
| const boxY = boardY + shelfBoardThickness / 2 + 0.3 / 2; | |
| const boxZ = 0; | |
| box.position.set(boxX, boxY, boxZ); | |
| box.castShadow = true; box.receiveShadow = true; | |
| box.userData = { | |
| id: boxId, // Assign the correct sequential ID | |
| originalMaterial: boxMaterial.clone(), | |
| archiveInfo: null, // Will be linked by linkArchiveDataToBoxes | |
| isBlinking: false | |
| }; | |
| shelfGroup.add(box); | |
| boxes.push(box); | |
| boxCreationCounter++; // Increment the counter for the next box | |
| } | |
| if (boxCreationCounter >= allBoxIds.length) break; // Stop creating levels if done | |
| } | |
| if (boxCreationCounter >= allBoxIds.length) break; // Stop creating shelves if done | |
| // --- Add Shelf Label --- | |
| const shelfLabelDiv = document.createElement('div'); | |
| shelfLabelDiv.className = 'shelf-label'; | |
| shelfLabelDiv.textContent = `Rak B${r + 1}-R${s + 1}`; | |
| const shelfLabel = new THREE.CSS2DObject(shelfLabelDiv); | |
| const labelZOffset = shelfDepth / 2 + 0.1; | |
| shelfLabel.position.set(0, shelfHeight / 2 + 0.2, labelZOffset); | |
| shelfGroup.add(shelfLabel); | |
| shelfLabels.push(shelfLabel); | |
| // Position and rotate the entire shelf group | |
| shelfGroup.position.set(shelfX, -roomHeight / 2 + shelfHeight / 2, shelfZ); | |
| shelfGroup.rotation.y = rotationY; | |
| scene.add(shelfGroup); | |
| shelves.push(shelfGroup); | |
| } | |
| if (boxCreationCounter >= allBoxIds.length) break; // Stop creating rows if done | |
| } | |
| totalBoxesCreated = boxes.length; // Store the total number of boxes created | |
| console.log(`Created ${shelves.length} shelves, ${totalBoxesCreated} boxes, ${shelfLabels.length} labels, and ${rails.length} rail segments.`); | |
| linkArchiveDataToBoxes(); // Link the defaultDummyData (now limited to 30) | |
| populateArchiveList(archiveData); // Populate list with the limited data | |
| buildNavigationGrid(); // *** Build the grid after creating shelves *** | |
| } | |
| // === Archive List Population === | |
| const archiveListElement = document.getElementById('archive-list'); | |
| function populateArchiveList(data) { /* ... (populateArchiveList function remains the same) ... */ | |
| archiveListElement.innerHTML = ''; | |
| if (!archiveData || archiveData.length === 0) { | |
| archiveListElement.innerHTML = '<li>Data arsip kosong. Muat ulang atau unggah file JSON.</li>'; | |
| return; | |
| } | |
| archiveData.forEach(item => { | |
| if (item.nama_arsip && item.lokasi_rak) { | |
| const listItem = document.createElement('li'); | |
| listItem.textContent = item.nama_arsip; | |
| listItem.title = `${item.nama_arsip} (ID: ${item.id || 'N/A'}, Lokasi: ${item.lokasi_rak})`; | |
| listItem.dataset.location = item.lokasi_rak; | |
| listItem.dataset.archiveId = item.id || ''; | |
| archiveListElement.appendChild(listItem); | |
| } | |
| }); | |
| } | |
| // === Link Archive Data to 3D Boxes === | |
| function linkArchiveDataToBoxes() { /* ... (linkArchiveDataToBoxes function remains the same) ... */ | |
| occupiedBoxesCount = 0; | |
| if (!archiveData || archiveData.length === 0) { | |
| console.log("No archive data to link."); | |
| updateStorageInfo(); | |
| return; | |
| } | |
| boxes.forEach(box => { | |
| box.userData.archiveInfo = null; | |
| box.userData.isBlinking = false; | |
| if (box.material !== box.userData.originalMaterial && highlightedBox !== box) { | |
| if(box.userData.originalMaterial) { | |
| box.material = box.userData.originalMaterial; | |
| } else { | |
| box.material = boxMaterial; | |
| } | |
| } | |
| }); | |
| let linkedCount = 0; | |
| archiveData.forEach(item => { | |
| if (item.lokasi_rak) { | |
| const targetBox = boxes.find(box => box.userData.id.toUpperCase() === item.lokasi_rak.toUpperCase()); | |
| if (targetBox) { | |
| targetBox.userData.archiveInfo = item; | |
| linkedCount++; | |
| } | |
| } else { | |
| console.warn(`Archive item "${item.nama_arsip || item.id}" is missing 'lokasi_rak'.`); | |
| } | |
| }); | |
| occupiedBoxesCount = linkedCount; | |
| console.log(`Linked ${linkedCount} archive data items to ${boxes.length} 3D boxes.`); | |
| updateStorageInfo(); | |
| } | |
| // === Controls === | |
| const orbitControls = new THREE.OrbitControls(camera, renderer.domElement); /* ... (OrbitControls setup remains the same) ... */ | |
| orbitControls.enableDamping = true; | |
| orbitControls.dampingFactor = 0.05; | |
| orbitControls.screenSpacePanning = false; | |
| orbitControls.maxPolarAngle = Math.PI / 1.9; | |
| orbitControls.minDistance = 1; | |
| orbitControls.maxDistance = 50; | |
| orbitControls.target.set(0, 0, 0); | |
| const pointerLockControls = new THREE.PointerLockControls(camera, document.body); /* ... (PointerLockControls setup remains the same) ... */ | |
| scene.add(pointerLockControls.getObject()); | |
| let moveForward = false, moveBackward = false, moveLeft = false, moveRight = false; | |
| const velocity = new THREE.Vector3(); | |
| const direction = new THREE.Vector3(); | |
| const moveSpeed = 5.0; | |
| 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; } }; | |
| 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; } }; | |
| document.addEventListener('keydown', onKeyDown); | |
| document.addEventListener('keyup', onKeyUp); | |
| 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 | |
| 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 | |
| renderer.domElement.addEventListener('click', () => { if (!isOrbitView && !pointerLockControls.isLocked) pointerLockControls.lock(); }); | |
| // === View Toggle === | |
| let isOrbitView = true; | |
| const toggleViewButton = document.getElementById('toggle-view-button'); | |
| // const instructions = document.getElementById('instructions'); // Removed reference | |
| const controlsContainer = document.getElementById('controls-container'); | |
| const archiveListContainer = document.getElementById('archive-list-container'); | |
| const environmentInfoPanel = document.getElementById('environment-info'); | |
| const analyticsPanel = document.getElementById('analytics-panel'); // Get reference | |
| camera.position.set(roomWidth * 0.6, roomHeight * 1.2, roomDepth * 0.6); | |
| orbitControls.update(); | |
| toggleViewButton.addEventListener('click', () => { | |
| isOrbitView = !isOrbitView; | |
| if (isOrbitView) { | |
| // Switch TO Orbit View | |
| if (pointerLockControls.isLocked) pointerLockControls.unlock(); // Unlock first | |
| pointerLockControls.enabled = false; | |
| orbitControls.enabled = true; | |
| archiveListContainer.style.display = 'block'; | |
| labelRenderer.domElement.style.display = 'block'; // Show labels | |
| environmentInfoPanel.style.display = 'block'; // Show env info | |
| analyticsPanel.style.display = 'block'; // Show analytics | |
| toggleViewButton.textContent = 'Ganti ke Mode Jalan'; | |
| // instructions.innerHTML = `...`; // Removed update | |
| const fpvPosition = pointerLockControls.getObject().position; | |
| camera.position.copy(fpvPosition); | |
| camera.position.y = Math.max(camera.position.y, -roomHeight / 2 + 0.5); | |
| const lookDirection = new THREE.Vector3(); | |
| camera.getWorldDirection(lookDirection); | |
| orbitControls.target.copy(fpvPosition).add(lookDirection.multiplyScalar(2)); | |
| orbitControls.update(); | |
| } else { | |
| // Switch TO First-Person View | |
| orbitControls.enabled = false; | |
| pointerLockControls.enabled = true; | |
| archiveListContainer.style.display = 'none'; | |
| labelRenderer.domElement.style.display = 'none'; // Hide labels | |
| environmentInfoPanel.style.display = 'none'; // Hide env info | |
| analyticsPanel.style.display = 'none'; // Hide analytics | |
| toggleViewButton.textContent = 'Ganti ke Mode Orbit'; | |
| // instructions.innerHTML = `...`; // Removed update | |
| pointerLockControls.getObject().position.y = (-roomHeight / 2) + playerEyeHeight; | |
| pointerLockControls.getObject().position.x = camera.position.x; | |
| pointerLockControls.getObject().position.z = camera.position.z; | |
| } | |
| }); | |
| // === JSON File Upload (REMOVED) === | |
| // const jsonUploadInput = document.getElementById('json-upload'); | |
| // jsonUploadInput.addEventListener('change', (event) => { ... }); | |
| // === A* Pathfinding Implementation === | |
| class PathNode { | |
| constructor(x, z, g = 0, h = 0, parent = null) { | |
| this.x = x; // Grid x coordinate | |
| this.z = z; // Grid z coordinate (using z for grid height) | |
| this.g = g; // Cost from start | |
| this.h = h; // Heuristic cost to end | |
| this.f = g + h; // Total cost | |
| this.parent = parent; // Parent node for path reconstruction | |
| } | |
| // Helper to check if two nodes are the same position | |
| equals(otherNode) { | |
| return this.x === otherNode.x && this.z === otherNode.z; | |
| } | |
| } | |
| // Heuristic function (Manhattan distance) | |
| function heuristic(nodeA, nodeB) { | |
| return Math.abs(nodeA.x - nodeB.x) + Math.abs(nodeA.z - nodeB.z); | |
| } | |
| function findPathAStar(startX, startZ, endX, endZ) { | |
| const startNode = new PathNode(startX, startZ); | |
| const endNode = new PathNode(endX, endZ); | |
| const openList = [startNode]; | |
| const closedList = []; | |
| while (openList.length > 0) { | |
| // Find node with lowest f score in openList | |
| let lowestFIndex = 0; | |
| for (let i = 1; i < openList.length; i++) { | |
| if (openList[i].f < openList[lowestFIndex].f) { | |
| lowestFIndex = i; | |
| } | |
| } | |
| const currentNode = openList[lowestFIndex]; | |
| // Move current node from open to closed list | |
| openList.splice(lowestFIndex, 1); | |
| closedList.push(currentNode); | |
| // Check if we reached the end | |
| if (currentNode.equals(endNode)) { | |
| // Reconstruct path | |
| const path = []; | |
| let temp = currentNode; | |
| while (temp !== null) { | |
| path.push({ x: temp.x, z: temp.z }); | |
| temp = temp.parent; | |
| } | |
| return path.reverse(); // Return path from start to end | |
| } | |
| // Get neighbors (up, down, left, right) | |
| const neighbors = []; | |
| const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]; // Right, Left, Down, Up | |
| for (const dir of directions) { | |
| const neighborX = currentNode.x + dir[0]; | |
| const neighborZ = currentNode.z + dir[1]; | |
| // Check bounds and if walkable | |
| if (neighborX >= 0 && neighborX < gridWidth && | |
| neighborZ >= 0 && neighborZ < gridHeight && | |
| navigationGrid[neighborZ][neighborX] === 0) { // 0 means walkable | |
| neighbors.push(new PathNode(neighborX, neighborZ)); | |
| } | |
| } | |
| // Process neighbors | |
| for (const neighbor of neighbors) { | |
| // Skip if neighbor is in closed list | |
| if (closedList.some(node => node.equals(neighbor))) { | |
| continue; | |
| } | |
| const gScore = currentNode.g + 1; // Assuming cost of 1 for adjacent move | |
| let gScoreIsBest = false; | |
| // Check if neighbor is not in open list | |
| const openNodeIndex = openList.findIndex(node => node.equals(neighbor)); | |
| if (openNodeIndex === -1) { | |
| gScoreIsBest = true; | |
| neighbor.h = heuristic(neighbor, endNode); | |
| openList.push(neighbor); | |
| } else if (gScore < openList[openNodeIndex].g) { | |
| gScoreIsBest = true; | |
| } | |
| if (gScoreIsBest) { | |
| neighbor.parent = currentNode; | |
| neighbor.g = gScore; | |
| neighbor.f = neighbor.g + neighbor.h; | |
| // If neighbor was already in openList, update it (needed if using Priority Queue) | |
| // For simple array, adding again works but is less efficient. | |
| // If already in open list and score improved, update its parent and scores. | |
| if (openNodeIndex !== -1) { | |
| openList[openNodeIndex] = neighbor; | |
| } | |
| } | |
| } | |
| } | |
| // No path found | |
| console.warn("A* Pathfinding: No path found!"); | |
| return null; | |
| } | |
| // Function to convert world coordinates to grid coordinates | |
| function worldToGrid(worldX, worldZ) { | |
| const gridX = Math.floor((worldX - gridOriginOffset.x) / GRID_RESOLUTION); | |
| const gridZ = Math.floor((worldZ - gridOriginOffset.z) / GRID_RESOLUTION); | |
| // Clamp to grid boundaries | |
| const clampedX = Math.max(0, Math.min(gridWidth - 1, gridX)); | |
| const clampedZ = Math.max(0, Math.min(gridHeight - 1, gridZ)); | |
| return { x: clampedX, z: clampedZ }; | |
| } | |
| // Function to convert grid coordinates back to world coordinates (center of cell) | |
| function gridToWorld(gridX, gridZ) { | |
| const worldX = gridX * GRID_RESOLUTION + gridOriginOffset.x + GRID_RESOLUTION / 2; | |
| const worldZ = gridZ * GRID_RESOLUTION + gridOriginOffset.z + GRID_RESOLUTION / 2; | |
| return new THREE.Vector3(worldX, -roomHeight / 2 + 0.1, worldZ); // Y slightly above floor | |
| } | |
| // Function to build the navigation grid | |
| function buildNavigationGrid() { | |
| gridWidth = Math.ceil(roomWidth / GRID_RESOLUTION); | |
| gridHeight = Math.ceil(roomDepth / GRID_RESOLUTION); | |
| gridOriginOffset.x = -roomWidth / 2; | |
| gridOriginOffset.z = -roomDepth / 2; | |
| // Initialize grid with 0 (walkable) | |
| navigationGrid = Array(gridHeight).fill(null).map(() => Array(gridWidth).fill(0)); | |
| // Mark cells occupied by shelves as 1 (blocked) | |
| shelves.forEach(shelf => { | |
| const shelfPos = shelf.position; | |
| const halfShelfWidth = shelfWidth / 2; | |
| const halfShelfDepth = shelfDepth / 2; // Use actual shelf depth | |
| // Calculate bounding box in world coordinates, considering rotation | |
| // Create a temporary Box3 helper | |
| const boxHelper = new THREE.Box3(); | |
| // Set the helper from the shelf group (which includes all panels) | |
| // This automatically considers the group's position and rotation | |
| boxHelper.setFromObject(shelf); | |
| // Convert world bounds to grid bounds | |
| const minGrid = worldToGrid(boxHelper.min.x, boxHelper.min.z); | |
| const maxGrid = worldToGrid(boxHelper.max.x, boxHelper.max.z); | |
| // Mark grid cells within the bounds as blocked | |
| for (let z = minGrid.z; z <= maxGrid.z; z++) { | |
| for (let x = minGrid.x; x <= maxGrid.x; x++) { | |
| if (x >= 0 && x < gridWidth && z >= 0 && z < gridHeight) { | |
| navigationGrid[z][x] = 1; // Mark as blocked | |
| } | |
| } | |
| } | |
| }); | |
| console.log("Navigation grid built."); | |
| // console.log(navigationGrid); // Optional: Log grid for debugging | |
| } | |
| // === Pathfinding and Highlighting Logic (dipicu oleh klik daftar) === | |
| let highlightedBox = null; | |
| let pathLine = null; | |
| const blinkMaterial = new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0x555500 }); | |
| const pathMaterialBlue = new THREE.LineBasicMaterial({ color: 0x0000ff, linewidth: 5 }); | |
| const avgRetrievalTimeElement = document.getElementById('avg-retrieval-time'); | |
| function findAndShowPath(locationId) { | |
| const normalizedLocationId = locationId.trim().toUpperCase(); | |
| if (!normalizedLocationId) return; | |
| // Reset previous highlight and path | |
| if (highlightedBox) { | |
| highlightedBox.userData.isBlinking = false; | |
| if (highlightedBox.userData.originalMaterial) { | |
| highlightedBox.material = highlightedBox.userData.originalMaterial; | |
| } else { | |
| highlightedBox.material = boxMaterial; | |
| } | |
| const parentShelf = highlightedBox.parent; | |
| if (parentShelf && parentShelf.userData.panels) { | |
| const tempColor = getTemperatureColor(parentShelf.userData.temperature); | |
| parentShelf.userData.panels.forEach(panel => { | |
| panel.material.color.copy(tempColor); | |
| }); | |
| } | |
| highlightedBox = null; | |
| } | |
| if (pathLine) { | |
| scene.remove(pathLine); | |
| pathLine.geometry.dispose(); | |
| pathLine = null; | |
| } | |
| const currentlySelected = archiveListElement.querySelector('.selected'); | |
| if (currentlySelected) { | |
| currentlySelected.classList.remove('selected'); | |
| } | |
| let foundBox = null; | |
| let archiveInfo = null; | |
| const pathMaterial = pathMaterialBlue; | |
| foundBox = boxes.find(box => box.userData.id.toUpperCase() === normalizedLocationId); | |
| if (foundBox) { | |
| archiveInfo = foundBox.userData.archiveInfo; | |
| if (!archiveInfo || archiveInfo.nama_arsip?.startsWith("(Data Arsip Kosong")) { | |
| showMessage(`Boks ${foundBox.userData.id} kosong (tidak ada data arsip).`, true); | |
| const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`); | |
| if (listItem) { | |
| listItem.classList.remove('selected'); | |
| } | |
| return; | |
| } | |
| accessFrequency[locationId] = (accessFrequency[locationId] || 0) + 1; | |
| const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`); | |
| if (listItem) { | |
| listItem.classList.add('selected'); | |
| } | |
| highlightedBox = foundBox; | |
| highlightedBox.userData.isBlinking = true; | |
| if (!highlightedBox.userData.originalMaterial) { | |
| highlightedBox.userData.originalMaterial = highlightedBox.material.clone(); | |
| } | |
| highlightedBox.material = blinkMaterial; | |
| // --- Path Calculation using A* --- | |
| const pathStartY = -roomHeight / 2 + 0.1; | |
| const startWorld = new THREE.Vector3(0, pathStartY, roomDepth / 2 - 1); // Start slightly inside entrance | |
| // Calculate target world position (in front of the shelf) | |
| const parentShelf = foundBox.parent; | |
| const shelfPosition = parentShelf.position; | |
| const shelfRotationY = parentShelf.rotation.y; | |
| // Target point in front of the shelf center, considering rotation | |
| const targetOffsetZ = shelfDepth / 2 + 0.5; // Distance in front | |
| const targetWorld = new THREE.Vector3(0, pathStartY, targetOffsetZ); | |
| targetWorld.applyEuler(parentShelf.rotation); // Apply shelf rotation | |
| targetWorld.add(shelfPosition); // Add shelf position | |
| // Convert world coordinates to grid coordinates | |
| const startGrid = worldToGrid(startWorld.x, startWorld.z); | |
| const endGrid = worldToGrid(targetWorld.x, targetWorld.z); | |
| // Ensure endGrid is walkable, if not, find nearest walkable neighbor (simple approach) | |
| if (navigationGrid[endGrid.z][endGrid.x] === 1) { | |
| console.warn("Target grid cell is blocked, finding nearest walkable..."); | |
| const directions = [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]]; | |
| let foundWalkable = false; | |
| for (const dir of directions) { | |
| const checkX = endGrid.x + dir[0]; | |
| const checkZ = endGrid.z + dir[1]; | |
| if (checkX >= 0 && checkX < gridWidth && checkZ >= 0 && checkZ < gridHeight && navigationGrid[checkZ][checkX] === 0) { | |
| endGrid.x = checkX; | |
| endGrid.z = checkZ; | |
| foundWalkable = true; | |
| break; | |
| } | |
| } | |
| if (!foundWalkable) { | |
| showMessage(`Tidak dapat menemukan titik tujuan yang bisa dijangkau dekat rak ${locationId}.`, true); | |
| return; // Stop if no walkable target found nearby | |
| } | |
| } | |
| console.log(`Pathfinding from grid [${startGrid.x}, ${startGrid.z}] to [${endGrid.x}, ${endGrid.z}]`); | |
| const gridPath = findPathAStar(startGrid.x, startGrid.z, endGrid.x, endGrid.z); | |
| if (gridPath) { | |
| // Convert grid path back to world points | |
| const worldPathPoints = gridPath.map(node => gridToWorld(node.x, node.z)); | |
| // --- Record Aisle Access Frequency (based on path) --- | |
| const aisleZs = {}; | |
| for(let i = 1; i < worldPathPoints.length - 1; i++) { | |
| const zRounded = worldPathPoints[i].z.toFixed(1); | |
| aisleZs[zRounded] = (aisleZs[zRounded] || 0) + 1; | |
| } | |
| let mostFrequentZ = null; | |
| let maxCount = 0; | |
| for (const z in aisleZs) { | |
| if (aisleZs[z] > maxCount) { | |
| maxCount = aisleZs[z]; | |
| mostFrequentZ = z; | |
| } | |
| } | |
| if (mostFrequentZ) { | |
| const aisleKey = `Lorong Z=${mostFrequentZ}`; | |
| aisleAccessFrequency[aisleKey] = (aisleAccessFrequency[aisleKey] || 0) + 1; | |
| } | |
| // Draw the path line | |
| const pathGeometry = new THREE.BufferGeometry().setFromPoints(worldPathPoints); | |
| pathLine = new THREE.Line(pathGeometry, pathMaterial); | |
| pathLine.renderOrder = 1; | |
| scene.add(pathLine); | |
| // --- Calculate and Update Average Retrieval Time --- | |
| let pathLength = 0; | |
| for (let i = 0; i < worldPathPoints.length - 1; i++) { | |
| pathLength += worldPathPoints[i].distanceTo(worldPathPoints[i + 1]); | |
| } | |
| const estimatedTime = (pathLength / walkingSpeed) + fixedPickTime; | |
| retrievalTimes.push(estimatedTime); | |
| if (retrievalTimes.length > maxRetrievalTimes) { | |
| retrievalTimes.shift(); | |
| } | |
| const sum = retrievalTimes.reduce((a, b) => a + b, 0); | |
| const avgTime = sum / retrievalTimes.length; | |
| avgRetrievalTimeElement.textContent = avgTime.toFixed(1); | |
| } else { | |
| // Fallback to simple line if A* fails | |
| console.error("A* pathfinding failed, drawing simple line as fallback."); | |
| const simplePathPoints = [startWorld, targetWorld]; | |
| const pathGeometry = new THREE.BufferGeometry().setFromPoints(simplePathPoints); | |
| pathLine = new THREE.Line(pathGeometry, pathMaterial); | |
| pathLine.renderOrder = 1; | |
| scene.add(pathLine); | |
| avgRetrievalTimeElement.textContent = "N/A"; // Indicate failure | |
| } | |
| // --- Camera Focus (Orbit Mode) --- | |
| if (isOrbitView) { | |
| const midPoint = new THREE.Vector3().lerpVectors(startWorld, targetWorld, 0.5); // Midpoint of path start/end | |
| orbitControls.target.copy(midPoint); | |
| const targetPosition = endPoint.clone().add(new THREE.Vector3(3, 3, 3)); // Look from near the box | |
| const duration = 0.8; | |
| const startTime = clock.getElapsedTime(); | |
| const startPosition = camera.position.clone(); | |
| function moveCamera() { | |
| const elapsed = clock.getElapsedTime() - startTime; | |
| const t = Math.min(elapsed / duration, 1); | |
| camera.position.lerpVectors(startPosition, targetPosition, t * (2 - t)); | |
| orbitControls.update(); | |
| if (t < 1) requestAnimationFrame(moveCamera); | |
| } | |
| requestAnimationFrame(moveCamera); | |
| } | |
| // --- Display Message --- | |
| let messageText = archiveInfo?.nama_arsip | |
| ? `Arsip "${archiveInfo.nama_arsip}" (ID: ${archiveInfo.id}) ditemukan di ${foundBox.userData.id}.` | |
| : `Boks ${foundBox.userData.id} ditemukan.`; | |
| if (archiveInfo?.deskripsi) { | |
| messageText += ` (${archiveInfo.deskripsi})`; | |
| } | |
| showMessage(messageText); | |
| } else { | |
| // Handle case where the clicked location ID doesn't match any box | |
| showMessage(`Lokasi boks "${locationId}" tidak ditemukan di scene.`, true); | |
| const listItem = archiveListElement.querySelector(`li[data-location="${locationId}"]`); | |
| if (listItem) { | |
| listItem.classList.remove('selected'); | |
| } | |
| } | |
| } | |
| // Event listener for the archive list | |
| archiveListElement.addEventListener('click', (event) => { | |
| if (event.target && event.target.nodeName === 'LI') { | |
| const locationId = event.target.dataset.location; | |
| if (locationId) { | |
| findAndShowPath(locationId); | |
| } | |
| } | |
| }); | |
| // === Analytics Panel Logic === | |
| const topAccessedListElement = document.getElementById('top-accessed-list'); | |
| const topAislesListElement = document.getElementById('top-aisles-list'); | |
| const updateAnalyticsBtn = document.getElementById('update-analytics-btn'); | |
| function updateAnalyticsPanel() { | |
| // --- Top Accessed Archives --- | |
| topAccessedListElement.innerHTML = ''; // Clear list | |
| const sortedAccess = Object.entries(accessFrequency) | |
| .sort(([, a], [, b]) => b - a) // Sort by count descending | |
| .slice(0, 10); // Get top 10 | |
| if (sortedAccess.length > 0) { | |
| sortedAccess.forEach(([locationId, count]) => { | |
| const archiveItem = archiveData.find(item => item.lokasi_rak === locationId); | |
| const displayName = archiveItem ? archiveItem.nama_arsip.substring(0, 20) + '...' : locationId; // Show name or ID | |
| const li = document.createElement('li'); | |
| li.textContent = `${displayName}: `; | |
| const countSpan = document.createElement('span'); | |
| countSpan.textContent = count; | |
| li.appendChild(countSpan); | |
| li.title = archiveItem ? `${archiveItem.nama_arsip} (${locationId}) - ${count} kali` : `${locationId} - ${count} kali`; | |
| topAccessedListElement.appendChild(li); | |
| }); | |
| } else { | |
| topAccessedListElement.innerHTML = '<li>Belum ada data akses.</li>'; | |
| } | |
| // --- Top Used Aisles --- | |
| topAislesListElement.innerHTML = ''; // Clear list | |
| const sortedAisles = Object.entries(aisleAccessFrequency) | |
| .sort(([, a], [, b]) => b - a) // Sort by count descending | |
| .slice(0, 5); // Get top 5 | |
| if (sortedAisles.length > 0) { | |
| sortedAisles.forEach(([aisleKey, count]) => { | |
| const li = document.createElement('li'); | |
| li.textContent = `${aisleKey}: `; | |
| const countSpan = document.createElement('span'); | |
| countSpan.textContent = count; | |
| li.appendChild(countSpan); | |
| li.title = `${aisleKey} - ${count} kali`; | |
| topAislesListElement.appendChild(li); | |
| }); | |
| } else { | |
| topAislesListElement.innerHTML = '<li>Belum ada data lorong.</li>'; | |
| } | |
| } | |
| // Add event listener to the button | |
| updateAnalyticsBtn.addEventListener('click', updateAnalyticsPanel); | |
| // === Animation Loop === | |
| const clock = new THREE.Clock(); | |
| const blinkRate = 0.5; | |
| let envUpdateInterval = 2; // Update environment info every 2 seconds | |
| let timeSinceLastEnvUpdate = 0; | |
| let heatmapUpdateInterval = 1; // Update heatmap every 1 second | |
| let timeSinceLastHeatmapUpdate = 0; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| const elapsedTime = clock.getElapsedTime(); | |
| timeSinceLastEnvUpdate += delta; | |
| timeSinceLastHeatmapUpdate += delta; | |
| // --- Update Environment Info Periodically --- | |
| if (timeSinceLastEnvUpdate >= envUpdateInterval) { | |
| updateEnvironmentInfo(); | |
| timeSinceLastEnvUpdate = 0; // Reset timer | |
| } | |
| // --- Update Shelf Temperatures & Heatmap Periodically --- | |
| if (timeSinceLastHeatmapUpdate >= heatmapUpdateInterval) { | |
| updateShelfTemperatures(); | |
| timeSinceLastHeatmapUpdate = 0; // Reset timer | |
| } | |
| // --- Blinking Logic --- | |
| if (highlightedBox && highlightedBox.userData.isBlinking) { | |
| const blinkPhase = (elapsedTime / blinkRate) % 1.0; | |
| if (blinkPhase < 0.5) { | |
| highlightedBox.material = blinkMaterial; | |
| } else { | |
| if(highlightedBox.userData.originalMaterial) { | |
| highlightedBox.material = highlightedBox.userData.originalMaterial; | |
| } else { | |
| highlightedBox.material = boxMaterial; // Fallback | |
| } | |
| } | |
| } | |
| // --- Control Updates --- | |
| if (isOrbitView) { | |
| orbitControls.update(); | |
| // *** Raycasting for Hover Effect (only in Orbit view) *** | |
| handleHover(); | |
| } else if (pointerLockControls.isLocked === true) { | |
| // FPV Movement Logic | |
| velocity.x -= velocity.x * 10.0 * delta; | |
| velocity.z -= velocity.z * 10.0 * delta; | |
| direction.z = Number(moveForward) - Number(moveBackward); | |
| direction.x = Number(moveRight) - Number(moveLeft); | |
| direction.normalize(); | |
| const currentSpeed = moveSpeed * delta * 10; | |
| if (moveForward || moveBackward) velocity.z -= direction.z * currentSpeed; | |
| if (moveLeft || moveRight) velocity.x -= direction.x * currentSpeed; | |
| pointerLockControls.moveRight(-velocity.x * delta); | |
| pointerLockControls.moveForward(-velocity.z * delta); | |
| const camPos = pointerLockControls.getObject().position; | |
| const padding = 0.5; | |
| camPos.x = Math.max(-roomWidth / 2 + padding, Math.min(roomWidth / 2 - padding, camPos.x)); | |
| camPos.z = Math.max(-roomDepth / 2 + padding, Math.min(roomDepth / 2 - padding, camPos.z)); | |
| // *** Keep camera height constant *** | |
| camPos.y = (-roomHeight / 2) + playerEyeHeight; | |
| } | |
| // --- Rendering --- | |
| renderer.render(scene, camera); | |
| if (isOrbitView) { | |
| labelRenderer.render(scene, camera); | |
| } | |
| } | |
| // === Environment Info Simulation === | |
| const tempValueElement = document.getElementById('temp-value'); | |
| const humidityValueElement = document.getElementById('humidity-value'); | |
| const lightValueElement = document.getElementById('light-value'); | |
| const totalBoxesValueElement = document.getElementById('total-boxes-value'); | |
| const utilityValueElement = document.getElementById('utility-value'); | |
| const borrowedValueElement = document.getElementById('borrowed-value'); | |
| // avgRetrievalTimeElement defined with other pathfinding vars | |
| const baseTemp = 21; // Base temperature in °C | |
| const tempFluctuation = 1.5; // Max fluctuation +/- | |
| const baseHumidity = 55; // Base humidity in % | |
| const humidityFluctuation = 5; // Max fluctuation +/- | |
| const baseLight = 100; // Base light level in Lux | |
| const lightFluctuation = 25; // Max fluctuation +/- | |
| let simulatedBorrowedCount = 3; // Initial borrowed count | |
| function updateEnvironmentInfo() { | |
| // Calculate average temperature from all shelves for display | |
| let totalTemp = 0; | |
| shelves.forEach(shelf => totalTemp += shelf.userData.temperature); | |
| const currentAvgTemp = shelves.length > 0 ? totalTemp / shelves.length : baseTemp; | |
| tempValueElement.textContent = currentAvgTemp.toFixed(1); | |
| // Simulate humidity | |
| const currentHumidity = baseHumidity + (Math.random() * 2 - 1) * humidityFluctuation; | |
| humidityValueElement.textContent = Math.round(currentHumidity); | |
| // Simulate light | |
| const currentLight = baseLight + (Math.random() * 2 - 1) * lightFluctuation; | |
| lightValueElement.textContent = Math.round(currentLight); | |
| // Simulate borrowed count fluctuation | |
| if (Math.random() < 0.1) { | |
| simulatedBorrowedCount += (Math.random() < 0.5 ? -1 : 1); | |
| simulatedBorrowedCount = Math.max(0, Math.min(simulatedBorrowedCount, occupiedBoxesCount)); | |
| } | |
| borrowedValueElement.textContent = simulatedBorrowedCount; | |
| } | |
| // Function to update only storage-related info | |
| function updateStorageInfo() { | |
| totalBoxesValueElement.textContent = totalBoxesCreated; | |
| const utilityPercentage = totalBoxesCreated > 0 ? (occupiedBoxesCount / totalBoxesCreated) * 100 : 0; | |
| utilityValueElement.textContent = utilityPercentage.toFixed(1); | |
| simulatedBorrowedCount = Math.min(simulatedBorrowedCount, occupiedBoxesCount); | |
| borrowedValueElement.textContent = simulatedBorrowedCount; | |
| } | |
| // === Heatmap Logic === | |
| function getTemperatureColor(temp) { | |
| if (temp < TEMP_IDEAL_MIN) { | |
| // Blue range (colder = more blue) | |
| const factor = Math.max(0, (temp - TEMP_COLD) / (TEMP_IDEAL_MIN - TEMP_COLD)); // 0 at TEMP_COLD, 1 at TEMP_IDEAL_MIN | |
| return new THREE.Color().lerpColors(colorCold, colorIdeal, factor); | |
| } else if (temp > TEMP_IDEAL_MAX) { | |
| // Red range (hotter = more red) | |
| const factor = Math.min(1, (temp - TEMP_IDEAL_MAX) / (TEMP_HOT - TEMP_IDEAL_MAX)); // 0 at TEMP_IDEAL_MAX, 1 at TEMP_HOT | |
| return new THREE.Color().lerpColors(colorIdeal, colorHot, factor); | |
| } else { | |
| // Ideal range (green) | |
| return colorIdeal.clone(); | |
| } | |
| } | |
| function updateShelfTemperatures() { | |
| shelves.forEach(shelfGroup => { | |
| // Simulate temperature fluctuation based on whether it's anomalous | |
| if (shelfGroup.userData.isAnomalous) { | |
| // Keep temperature around its anomaly zone with small fluctuations | |
| const anomalyBase = (shelfGroup.userData.creationIndex === 5) ? TEMP_HOT : TEMP_COLD; | |
| shelfGroup.userData.temperature = anomalyBase + (Math.random() * 2 - 1); // Smaller fluctuation around anomaly | |
| } else { | |
| // Normal fluctuation around base temperature | |
| shelfGroup.userData.temperature += (Math.random() * 0.4 - 0.2); // Smaller fluctuation | |
| } | |
| // Clamp temperature within reasonable bounds (e.g., 10 to 35) | |
| shelfGroup.userData.temperature = Math.max(10, Math.min(35, shelfGroup.userData.temperature)); | |
| const tempColor = getTemperatureColor(shelfGroup.userData.temperature); | |
| // Apply color to panels (only if not currently highlighted/blinking) | |
| // Note: Blinking logic in animate() will override this temporarily | |
| if (!highlightedBox || highlightedBox.parent !== shelfGroup) { | |
| shelfGroup.userData.panels.forEach(panel => { | |
| panel.material.color.copy(tempColor); | |
| }); | |
| } | |
| }); | |
| } | |
| // === Hover Effect Logic === | |
| function handleHover() { | |
| raycaster.setFromCamera(mouse, camera); | |
| // Intersect with the specific array of shelf panels | |
| const intersects = raycaster.intersectObjects(shelfPanels); | |
| if (intersects.length > 0) { | |
| const intersectedPanel = intersects[0].object; | |
| // Traverse up to find the parent shelfGroup | |
| let parentShelf = intersectedPanel.parent; | |
| while (parentShelf && !(parentShelf instanceof THREE.Group && parentShelf.userData.panels)) { | |
| parentShelf = parentShelf.parent; | |
| } | |
| if (parentShelf && parentShelf !== hoveredShelf) { | |
| hoveredShelf = parentShelf; | |
| const temp = hoveredShelf.userData.temperature; | |
| tempTooltipElement.textContent = `Suhu Rak: ${temp.toFixed(1)} °C`; | |
| tempTooltipElement.style.display = 'block'; | |
| // Position tooltip near mouse - adjust offsets as needed | |
| tempTooltipElement.style.left = `${mouse.clientX + 15}px`; | |
| tempTooltipElement.style.top = `${mouse.clientY - 10}px`; | |
| } else if (parentShelf === hoveredShelf) { | |
| // Update position if still hovering the same shelf | |
| tempTooltipElement.style.left = `${mouse.clientX + 15}px`; | |
| tempTooltipElement.style.top = `${mouse.clientY - 10}px`; | |
| } | |
| } else { | |
| // No intersection | |
| if (hoveredShelf) { | |
| hoveredShelf = null; | |
| tempTooltipElement.style.display = 'none'; | |
| } | |
| } | |
| } | |
| // === Event Listeners === | |
| // Mouse move listener for hover effect | |
| window.addEventListener('mousemove', (event) => { | |
| // calculate mouse position in normalized device coordinates | |
| // (-1 to +1) for both components | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = - (event.clientY / window.innerHeight) * 2 + 1; | |
| // Store clientX/Y for tooltip positioning | |
| mouse.clientX = event.clientX; | |
| mouse.clientY = event.clientY; | |
| }, false); | |
| // === Handle Window Resize === | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| labelRenderer.setSize(window.innerWidth, window.innerHeight); | |
| }, false); | |
| // === Initial Setup === | |
| window.onload = () => { | |
| // Assign the processed data (limited to 30) to the global archiveData | |
| archiveData = defaultDummyData; | |
| createSceneContent(); // Creates scene, builds grid, links data, populates list, updates storage info | |
| updateEnvironmentInfo(); // Initial environment display (temp, humidity, light, borrowed) | |
| updateShelfTemperatures(); // Initial heatmap coloring | |
| // Initialize average retrieval time display | |
| avgRetrievalTimeElement.textContent = "N/A"; | |
| updateAnalyticsPanel(); // Initial display for analytics panel | |
| animate(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |