simulations / dt_preservasi.html
jnm-itb
Implement structural updates and optimizations across multiple modules
e07e0a7
<!DOCTYPE html>
<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>