소개
공장 창고의 랙 배치와 물류 효율을 분석하고 싶었습니다. 기존에는 엑셀이나 CAD로 레이아웃을 그리고, 시뮬레이션은 전문 소프트웨어(FlexSim, AnyLogic 등)가 필요해서 시간도 비용도 많이 들었습니다. "실제 미터 단위로 랙을 배치하고, 지게차 동선까지 시뮬레이션해볼 수 없을까?" 하는 고민이 있었습니다
(내용 입력) 활용 이미지와 캡처 화면.
처음 시도한 화면
두번째 시도 - 시뮬레이션
세번째 시도 - 랙설치, 디지털 트윈 결합- 실제 창고 도면, 지게차, 창고인력등 실제 정보를 부면 What-If 시뮬레이션을 가능함.
진행 방법
Claude Code에게 단계적으로 요청하며 3개의 도구를 만들었습니다.
Step 1. 창고 Layout 설계 도구
"창고 layout 설계하는 것을 만들어 줘"로 시작
드래그&드롭으로 랙, 생산라인, 도크 등을 배치하는 웹 도구 생성
Step 2. 랙 + 미터 단위 추가
"랙 설치한 경우?"라고 물으니 Selective Rack 전용 기능 추가
"사이즈를 지정할 수 있어?" → 실제 미터(m) 단위 입력 기능 구현
Rack Auto-Place로 여러 열을 한 번에 자동 배치
Step 3. 디지털 트윈 시뮬레이션
"시뮬레이션을 만들어서 실행해줘" → 기본 시뮬레이션 생성
"디지털 트윈이 가능할까?" → 종합 디지털 트윈으로 업그레이드
통로 기반 경로 탐색 (Pathfinding)
ABC 분류 로케이션 전략
시간대별 수요 패턴 (Peak Hours)
병목 분석 & 자동 개선 제안
시나리오 비교 (지게차 증설, 고밀도 랙 등)
사용한 기능: 대화형 요청, 단계적 기능 확장, HTML/JS 코드 자동 생성
프롬프트 예시: "랙 설치한 경우?", "디지털 트윈이 가능할까?"처럼 짧은 질문으로 확장
(내용 입력)
결과와 배운 점
결과
Before
After
CAD + 전문 SW 필요
브라우저에서 바로 실행
레이아웃만 수일 소요
대화 한 세션(~30분)에 완성
시뮬레이션은 별도 비용
무료 HTML 파일 하나로 해결
정적 도면만 가능
실시간 동선·적재율·병목 분석 가능
산출물 3개:
warehouse-layout.html— 인터랙티브 창고 배치 도구warehouse-simulation.html— 기본 시뮬레이션warehouse-digital-twin.html— 종합 디지털 트윈
배운 점
처음부터 완벽한 요구사항을 주지 않아도, 대화하면서 점진적으로 발전시킬 수 있다는 것을 배웠습니다. "랙 배치는?" → "미터 단위로?" → "시뮬레이션도?" → "디지털 트윈까지?" 이렇게 한 단계씩 요청하니 Claude Code가 이전 맥락을 기억하고 자연스럽게 기능을 확장해줬습니다. 시행착오도 있었지만, 결과적으로 전문 소프트웨어 없이도 꽤 실용적인 분석 도구를 만들 수 있었습니다.
산출물 3개:
warehouse-layout.html— 인터랙티브 창고 배치 도구warehouse-simulation.html— 기본 시뮬레이션warehouse-digital-twin.html— 종합 디지털 트윈
코드
## 코드 전문
### 1. warehouse-layout.html (인터랙티브 창고 배치 도구)
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Factory Warehouse Layout Designer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 280px; background: #16213e; padding: 16px; display: flex; flex-direction: column; gap: 10px;
border-right: 2px solid #0f3460; overflow-y: auto;
}
.sidebar h2 { font-size: 18px; color: #e94560; margin-bottom: 4px; }
.sidebar h3 { font-size: 12px; color: #888; margin-top: 8px; text-transform: uppercase; letter-spacing: 1px; }
.element-btn {
display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid #0f3460;
border-radius: 8px; background: #1a1a2e; cursor: pointer; transition: all 0.2s; font-size: 13px; color: #ccc;
}
.element-btn:hover { border-color: #e94560; background: #16213e; color: #fff; }
.element-btn.active { border-color: #e94560; background: #2a1a3e; color: #fff; box-shadow: 0 0 8px rgba(233,69,96,0.3); }
.element-btn .color-dot { width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; }
.size-control { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
.size-control label { font-size: 12px; color: #888; width: 90px; }
.size-control input, .size-control select {
width: 70px; padding: 4px 6px; background: #1a1a2e; border: 1px solid #0f3460;
border-radius: 4px; color: #eee; font-size: 12px; text-align: center;
}
.size-control .unit { font-size: 11px; color: #666; width: 20px; }
.tool-btn {
padding: 8px 12px; border: 1px solid #0f3460; border-radius: 6px; background: #1a1a2e;
color: #ccc; cursor: pointer; font-size: 12px; transition: all 0.2s; text-align: center;
}
.tool-btn:hover { border-color: #e94560; color: #fff; }
.tool-btn.danger { border-color: #c0392b; }
.tool-btn.danger:hover { background: #c0392b; }
.tool-btn.primary { border-color: #e94560; background: #e94560; color: #fff; }
.tool-btn.primary:hover { background: #c0392b; }
.btn-row { display: flex; gap: 6px; }
.btn-row .tool-btn { flex: 1; }
.main { flex: 1; display: flex; flex-direction: column; }
.toolbar {
height: 48px; background: #16213e; display: flex; align-items: center; padding: 0 16px; gap: 12px;
border-bottom: 2px solid #0f3460; font-size: 13px;
}
.toolbar span { color: #888; }
.toolbar .info { margin-left: auto; color: #888; font-size: 12px; }
.canvas-container {
flex: 1; overflow: auto; position: relative; background: #111;
background-image: radial-gradient(circle, #222 1px, transparent 1px);
background-size: 20px 20px;
}
.canvas {
position: relative; margin: 40px; border: 2px solid #0f3460; background: #1a1a2e;
}
.grid-item {
position: absolute; border-radius: 4px; cursor: move; display: flex; align-items: center;
justify-content: center; font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.9);
text-shadow: 0 1px 2px rgba(0,0,0,0.5); user-select: none; transition: box-shadow 0.15s;
border: 2px solid rgba(255,255,255,0.15); flex-direction: column;
}
.grid-item:hover { box-shadow: 0 0 12px rgba(233,69,96,0.4); z-index: 100; }
.grid-item.selected { border-color: #e94560; box-shadow: 0 0 16px rgba(233,69,96,0.6); z-index: 101; }
.grid-item .label { text-align: center; pointer-events: none; padding: 2px; line-height: 1.2; }
.grid-item .dim-label { font-size: 9px; color: rgba(255,255,255,0.5); pointer-events: none; }
.grid-item .resize-handle {
position: absolute; right: -4px; bottom: -4px; width: 12px; height: 12px;
background: #e94560; border-radius: 2px; cursor: nwse-resize;
opacity: 0; transition: opacity 0.15s;
}
.grid-item:hover .resize-handle, .grid-item.selected .resize-handle { opacity: 1; }
.grid-item .delete-btn {
position: absolute; top: -8px; right: -8px; width: 18px; height: 18px;
background: #c0392b; border-radius: 50%; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 11px; font-weight: bold;
opacity: 0; transition: opacity 0.15s; border: none; color: #fff;
}
.grid-item:hover .delete-btn { opacity: 1; }
/* Rack patterns */
.grid-item.rack-item {
background-image: repeating-linear-gradient(
0deg, transparent, transparent 8px, rgba(255,255,255,0.08) 8px, rgba(255,255,255,0.08) 9px
);
}
.right-panel {
width: 250px; background: #16213e; border-left: 2px solid #0f3460; padding: 16px;
display: flex; flex-direction: column; gap: 8px; overflow-y: auto;
}
.right-panel h3 { font-size: 13px; color: #e94560; }
.prop-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.prop-row label { font-size: 12px; color: #888; width: 70px; }
.prop-row input, .prop-row select {
flex: 1; padding: 4px 6px; background: #1a1a2e; border: 1px solid #0f3460;
border-radius: 4px; color: #eee; font-size: 12px;
}
.prop-row .unit { font-size: 11px; color: #666; width: 20px; }
.stats-box { background: #1a1a2e; border-radius: 8px; padding: 12px; font-size: 12px; }
.stats-box .stat { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #0f3460; }
.stats-box .stat:last-child { border: none; }
.stats-box .stat-label { color: #888; }
.stats-box .stat-value { color: #e94560; font-weight: 600; }
.legend { display: flex; flex-wrap: wrap; gap: 4px; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #888; }
.legend-dot { width: 12px; height: 12px; border-radius: 3px; }
.context-menu {
position: fixed; background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
padding: 6px 0; min-width: 160px; z-index: 1000; display: none; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.context-menu .menu-item { padding: 8px 16px; cursor: pointer; font-size: 13px; color: #ccc; transition: background 0.15s; }
.context-menu .menu-item:hover { background: #0f3460; color: #fff; }
.context-menu .menu-divider { height: 1px; background: #0f3460; margin: 4px 0; }
.toast {
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: #0f3460;
color: #fff; padding: 10px 24px; border-radius: 8px; font-size: 13px; z-index: 2000;
opacity: 0; transition: opacity 0.3s; pointer-events: none;
}
.toast.show { opacity: 1; }
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6);
z-index: 1500; display: none; align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: #16213e; border: 1px solid #0f3460; border-radius: 12px; padding: 24px;
min-width: 400px; max-width: 500px;
}
.modal h3 { color: #e94560; margin-bottom: 16px; }
.modal textarea {
width: 100%; height: 200px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px;
color: #eee; padding: 10px; font-family: monospace; font-size: 12px; resize: vertical;
}
.modal .modal-btns { display: flex; gap: 8px; margin-top: 12px; justify-content: flex-end; }
/* Rack auto-place modal */
.rack-form { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: #1a1a2e; border-radius: 8px; }
.rack-form .form-row { display: flex; align-items: center; gap: 8px; }
.rack-form .form-row label { font-size: 12px; color: #888; width: 120px; }
.rack-form .form-row input {
width: 70px; padding: 4px 6px; background: #16213e; border: 1px solid #0f3460;
border-radius: 4px; color: #eee; font-size: 12px; text-align: center;
}
.rack-form .form-row .unit { font-size: 11px; color: #666; }
.scale-badge {
background: #0f3460; padding: 4px 10px; border-radius: 12px; font-size: 11px; color: #e94560; font-weight: 600;
}
/* Dimension overlay on canvas */
.dim-ruler-x, .dim-ruler-y {
position: absolute; background: rgba(15,52,96,0.7); display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #e94560; pointer-events: none;
}
.dim-ruler-x { bottom: -20px; left: 0; right: 0; height: 18px; }
.dim-ruler-y { top: 0; bottom: 0; right: -45px; width: 40px; writing-mode: vertical-rl; }
</style>
</head>
<body>
<div class="sidebar">
<h2>Warehouse Layout</h2>
<div style="display:flex; gap:6px; align-items:center;">
<span style="font-size:11px; color:#666;">Scale: 1 cell =</span>
<input type="number" id="scaleM" value="0.5" min="0.1" max="5" step="0.1" style="width:50px; padding:3px; background:#1a1a2e; border:1px solid #0f3460; border-radius:4px; color:#eee; font-size:12px; text-align:center;" onchange="updateScale()">
<span style="font-size:11px; color:#666;">m</span>
</div>
<h3>Warehouse Size</h3>
<div class="size-control">
<label>Width:</label>
<input type="number" id="whWidth" value="30" min="5" max="200" step="0.5" onchange="updateWarehouseSize()">
<span class="unit">m</span>
</div>
<div class="size-control">
<label>Depth:</label>
<input type="number" id="whDepth" value="20" min="5" max="200" step="0.5" onchange="updateWarehouseSize()">
<span class="unit">m</span>
</div>
<div class="size-control">
<label>Cell pixel:</label>
<input type="number" id="cellSize" value="24" min="10" max="50" onchange="updateWarehouseSize()">
<span class="unit">px</span>
</div>
<h3>Selective Rack</h3>
<div class="element-btn" data-type="rack-single" onclick="selectTool(this)">
<div class="color-dot" style="background:#e67e22;"></div> Single Rack
</div>
<div class="element-btn" data-type="rack-double" onclick="selectTool(this)">
<div class="color-dot" style="background:#d35400;"></div> Double Rack (Back-to-Back)
</div>
<button class="tool-btn primary" onclick="showRackAutoPlace()" style="margin-top:4px;">Rack Auto-Place (Row)</button>
<div class="rack-form" id="rackForm" style="display:none;">
<div style="font-size:12px; color:#e94560; font-weight:600;">Rack Auto-Place Settings</div>
<div class="form-row"><label>Rack Width:</label><input id="rackW" type="number" value="2.7" step="0.1"><span class="unit">m</span></div>
<div class="form-row"><label>Rack Depth:</label><input id="rackD" type="number" value="1.0" step="0.1"><span class="unit">m</span></div>
<div class="form-row"><label>Rack Type:</label>
<select id="rackType" style="width:100px; padding:4px; background:#16213e; border:1px solid #0f3460; border-radius:4px; color:#eee; font-size:12px;">
<option value="single">Single</option>
<option value="double" selected>Double (B2B)</option>
</select>
</div>
<div class="form-row"><label>Rack Levels:</label><input id="rackLevels" type="number" value="4" min="1" max="10"><span class="unit">levels</span></div>
<div class="form-row"><label>Rows:</label><input id="rackRows" type="number" value="5" min="1" max="30"><span class="unit">rows</span></div>
<div class="form-row"><label>Bays per Row:</label><input id="rackBays" type="number" value="8" min="1" max="50"><span class="unit">bays</span></div>
<div class="form-row"><label>Aisle Width:</label><input id="aisleW" type="number" value="3.0" step="0.1"><span class="unit">m</span></div>
<div class="form-row"><label>Start X:</label><input id="rackStartX" type="number" value="2.0" step="0.5"><span class="unit">m</span></div>
<div class="form-row"><label>Start Y:</label><input id="rackStartY" type="number" value="3.0" step="0.5"><span class="unit">m</span></div>
<div class="btn-row" style="margin-top:6px;">
<button class="tool-btn primary" onclick="autoPlaceRacks()">Place Racks</button>
<button class="tool-btn" onclick="hideRackAutoPlace()">Cancel</button>
</div>
</div>
<h3>Rack Placement Size</h3>
<div class="size-control">
<label>Width:</label>
<input type="number" id="placeW" value="2.7" min="0.5" max="20" step="0.1">
<span class="unit">m</span>
</div>
<div class="size-control">
<label>Depth:</label>
<input type="number" id="placeH" value="1.0" min="0.5" max="20" step="0.1">
<span class="unit">m</span>
</div>
<h3>Other Zones</h3>
<div class="element-btn" data-type="raw-material" onclick="selectTool(this)">
<div class="color-dot" style="background:#3498db;"></div> Raw Materials
</div>
<div class="element-btn" data-type="finished-goods" onclick="selectTool(this)">
<div class="color-dot" style="background:#2ecc71;"></div> Finished Goods
</div>
<div class="element-btn" data-type="wip-storage" onclick="selectTool(this)">
<div class="color-dot" style="background:#f39c12;"></div> WIP Storage
</div>
<div class="element-btn" data-type="production-line" onclick="selectTool(this)">
<div class="color-dot" style="background:#e74c3c;"></div> Production Line
</div>
<div class="element-btn" data-type="assembly" onclick="selectTool(this)">
<div class="color-dot" style="background:#e91e63;"></div> Assembly Area
</div>
<div class="element-btn" data-type="machine" onclick="selectTool(this)">
<div class="color-dot" style="background:#9b59b6;"></div> Machine/Equipment
</div>
<div class="element-btn" data-type="receiving-dock" onclick="selectTool(this)">
<div class="color-dot" style="background:#1abc9c;"></div> Receiving Dock
</div>
<div class="element-btn" data-type="shipping-dock" onclick="selectTool(this)">
<div class="color-dot" style="background:#16a085;"></div> Shipping Dock
</div>
<div class="element-btn" data-type="staging" onclick="selectTool(this)">
<div class="color-dot" style="background:#d4a017;"></div> Staging Area
</div>
<div class="element-btn" data-type="qc-area" onclick="selectTool(this)">
<div class="color-dot" style="background:#8e44ad;"></div> QC / Inspection
</div>
<div class="element-btn" data-type="office" onclick="selectTool(this)">
<div class="color-dot" style="background:#7f8c8d;"></div> Office
</div>
<div class="element-btn" data-type="aisle" onclick="selectTool(this)">
<div class="color-dot" style="background:rgba(255,255,255,0.15);border:1px solid #555;"></div> Aisle / Pathway
</div>
<h3>Actions</h3>
<div class="btn-row">
<button class="tool-btn" onclick="saveLayout()">Save</button>
<button class="tool-btn" onclick="loadLayout()">Load</button>
</div>
<div class="btn-row">
<button class="tool-btn" onclick="exportImage()">Export PNG</button>
<button class="tool-btn" onclick="exportJSON()">Export JSON</button>
</div>
<button class="tool-btn danger" onclick="clearAll()">Clear All</button>
</div>
<div class="main">
<div class="toolbar">
<span>Warehouse: <strong id="tbSize">30m x 20m</strong></span>
<span>|</span>
<span class="scale-badge" id="tbScale">1 cell = 0.5m</span>
<span>|</span>
<span>Items: <strong id="tbCount">0</strong></span>
<span>|</span>
<span>Selected: <strong id="tbSelected">None</strong></span>
<div class="info">Click element > Click canvas | Right-click for menu | Arrow keys to nudge</div>
</div>
<div class="canvas-container" id="canvasContainer">
<div class="canvas" id="canvas">
<div class="dim-ruler-x" id="rulerX">30.0m</div>
<div class="dim-ruler-y" id="rulerY">20.0m</div>
</div>
</div>
</div>
<div class="right-panel">
<h3>Properties</h3>
<div id="propPanel" style="font-size:12px; color:#666;">Select an item to edit</div>
<h3>Statistics</h3>
<div class="stats-box" id="statsBox">
<div class="stat"><span class="stat-label">Warehouse</span><span class="stat-value" id="statTotal">600.0 m2</span></div>
<div class="stat"><span class="stat-label">Used area</span><span class="stat-value" id="statUsed">0 m2</span></div>
<div class="stat"><span class="stat-label">Utilization</span><span class="stat-value" id="statUtil">0%</span></div>
<div class="stat"><span class="stat-label">Rack area</span><span class="stat-value" id="statRack">0 m2</span></div>
<div class="stat"><span class="stat-label">Rack count</span><span class="stat-value" id="statRackCount">0</span></div>
<div class="stat"><span class="stat-label">Total levels</span><span class="stat-value" id="statLevels">0</span></div>
<div class="stat"><span class="stat-label">Storage</span><span class="stat-value" id="statStorage">0 m2</span></div>
<div class="stat"><span class="stat-label">Production</span><span class="stat-value" id="statProd">0 m2</span></div>
<div class="stat"><span class="stat-label">Logistics</span><span class="stat-value" id="statLogistics">0 m2</span></div>
<div class="stat"><span class="stat-label">Aisle</span><span class="stat-value" id="statAisle">0 m2</span></div>
</div>
<h3>Legend</h3>
<div class="legend" id="legendBox"></div>
</div>
<div class="context-menu" id="contextMenu">
<div class="menu-item" onclick="duplicateItem()">Duplicate</div>
<div class="menu-item" onclick="renameItem()">Rename</div>
<div class="menu-divider"></div>
<div class="menu-item" onclick="bringToFront()">Bring to Front</div>
<div class="menu-item" onclick="sendToBack()">Send to Back</div>
<div class="menu-divider"></div>
<div class="menu-item" style="color:#e74c3c;" onclick="deleteSelectedItem()">Delete</div>
</div>
<div class="modal-overlay" id="jsonModal">
<div class="modal">
<h3 id="modalTitle">Export JSON</h3>
<textarea id="modalText"></textarea>
<div class="modal-btns">
<button class="tool-btn" onclick="copyModalText()">Copy</button>
<button class="tool-btn" onclick="importFromModal()">Import</button>
<button class="tool-btn" onclick="closeModal()">Close</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const TYPES = {
'rack-single': { label: 'Single Rack', color: '#e67e22', category: 'rack', isRack: true },
'rack-double': { label: 'Double Rack', color: '#d35400', category: 'rack', isRack: true },
'raw-material': { label: 'Raw Materials', color: '#3498db', category: 'storage' },
'finished-goods': { label: 'Finished Goods', color: '#2ecc71', category: 'storage' },
'wip-storage': { label: 'WIP Storage', color: '#f39c12', category: 'storage' },
'production-line':{ label: 'Production Line', color: '#e74c3c', category: 'production' },
'assembly': { label: 'Assembly', color: '#e91e63', category: 'production' },
'machine': { label: 'Machine', color: '#9b59b6', category: 'production' },
'receiving-dock': { label: 'Receiving Dock', color: '#1abc9c', category: 'logistics' },
'shipping-dock': { label: 'Shipping Dock', color: '#16a085', category: 'logistics' },
'staging': { label: 'Staging Area', color: '#d4a017', category: 'logistics' },
'qc-area': { label: 'QC / Inspection', color: '#8e44ad', category: 'support' },
'office': { label: 'Office', color: '#7f8c8d', category: 'support' },
'aisle': { label: 'Aisle', color: 'rgba(255,255,255,0.08)', category: 'aisle', border: '1px dashed rgba(255,255,255,0.2)' },
};
let items = [];
let nextId = 1;
let selectedType = null;
let selectedItem = null;
let scaleM = 0.5; // 1 cell = 0.5m
let cellSize = 24;
let whWidthM = 30, whDepthM = 20;
let gridCols, gridRows;
let dragging = null, resizing = null, dragOffX = 0, dragOffY = 0;
const canvas = document.getElementById('canvas');
function mToPx(m) { return Math.round(m / scaleM) * cellSize; }
function pxToM(px) { return (px / cellSize) * scaleM; }
function snap(val) { return Math.round(val / cellSize) * cellSize; }
function updateScale() {
scaleM = parseFloat(document.getElementById('scaleM').value) || 0.5;
document.getElementById('tbScale').textContent = '1 cell = ' + scaleM + 'm';
updateWarehouseSize();
}
function updateWarehouseSize() {
whWidthM = parseFloat(document.getElementById('whWidth').value) || 30;
whDepthM = parseFloat(document.getElementById('whDepth').value) || 20;
cellSize = parseInt(document.getElementById('cellSize').value) || 24;
scaleM = parseFloat(document.getElementById('scaleM').value) || 0.5;
gridCols = Math.ceil(whWidthM / scaleM);
gridRows = Math.ceil(whDepthM / scaleM);
canvas.style.width = gridCols * cellSize + 'px';
canvas.style.height = gridRows * cellSize + 'px';
canvas.style.backgroundSize = cellSize + 'px ' + cellSize + 'px';
canvas.style.backgroundImage = `
linear-gradient(to right, rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.04) 1px, transparent 1px)
`;
document.getElementById('tbSize').textContent = whWidthM + 'm x ' + whDepthM + 'm';
document.getElementById('tbScale').textContent = '1 cell = ' + scaleM + 'm';
document.getElementById('rulerX').textContent = whWidthM.toFixed(1) + 'm';
document.getElementById('rulerY').textContent = whDepthM.toFixed(1) + 'm';
updateStats();
}
function selectTool(btn) {
document.querySelectorAll('.element-btn').forEach(b => b.classList.remove('active'));
const type = btn.dataset.type;
if (selectedType === type) { selectedType = null; } else { selectedType = type; btn.classList.add('active'); }
}
canvas.addEventListener('click', function(e) {
if (dragging || resizing) return;
if (!selectedType) return;
if (e.target !== canvas) return;
const rect = canvas.getBoundingClientRect();
const x = snap(e.clientX - rect.left);
const y = snap(e.clientY - rect.top);
const info = TYPES[selectedType];
let wM, hM;
if (info && info.isRack) {
wM = parseFloat(document.getElementById('placeW').value) || 2.7;
hM = parseFloat(document.getElementById('placeH').value) || 1.0;
} else {
wM = parseFloat(document.getElementById('placeW').value) || 2.0;
hM = parseFloat(document.getElementById('placeH').value) || 2.0;
}
addItem(selectedType, x, y, mToPx(wM), mToPx(hM), null, info.isRack ? 4 : 0);
});
function addItem(type, x, y, w, h, customLabel, levels) {
const info = TYPES[type];
const item = {
id: nextId++, type, x, y, w, h,
label: customLabel || info.label,
levels: levels || 0,
zIndex: items.length + 1,
};
items.push(item);
renderItem(item);
updateStats();
return item;
}
function renderItem(item) {
const info = TYPES[item.type];
const el = document.createElement('div');
el.className = 'grid-item' + (info.isRack ? ' rack-item' : '');
el.dataset.id = item.id;
el.style.left = item.x + 'px';
el.style.top = item.y + 'px';
el.style.width = item.w + 'px';
el.style.height = item.h + 'px';
el.style.background = info.color;
el.style.zIndex = item.zIndex;
if (info.border) el.style.border = info.border;
if (info.isRack) {
el.style.backgroundImage = `repeating-linear-gradient(0deg, transparent, transparent ${Math.max(8, item.h/(item.levels||4)-1)}px, rgba(255,255,255,0.12) ${Math.max(8, item.h/(item.levels||4)-1)}px, rgba(255,255,255,0.12) ${Math.max(9, item.h/(item.levels||4))}px)`;
}
const wM = pxToM(item.w).toFixed(1);
const hM = pxToM(item.h).toFixed(1);
el.innerHTML = `
<div class="label">${item.label}</div>
<div class="dim-label">${wM}m x ${hM}m${item.levels ? ' | ' + item.levels + 'L' : ''}</div>
<div class="resize-handle"></div>
<button class="delete-btn" onclick="event.stopPropagation(); deleteItem(${item.id})">x</button>
`;
el.addEventListener('mousedown', function(e) {
if (e.target.classList.contains('resize-handle') || e.target.classList.contains('delete-btn')) return;
e.stopPropagation();
selectItem(item.id);
dragging = item;
const rect = el.getBoundingClientRect();
dragOffX = e.clientX - rect.left;
dragOffY = e.clientY - rect.top;
});
el.querySelector('.resize-handle').addEventListener('mousedown', function(e) {
e.stopPropagation();
selectItem(item.id);
resizing = item;
});
el.addEventListener('contextmenu', function(e) {
e.preventDefault(); e.stopPropagation();
selectItem(item.id);
showContextMenu(e.clientX, e.clientY);
});
canvas.appendChild(el);
}
function updateItemDOM(item) {
const el = canvas.querySelector(`[data-id="${item.id}"]`);
if (!el) return;
const info = TYPES[item.type];
el.style.left = item.x + 'px';
el.style.top = item.y + 'px';
el.style.width = item.w + 'px';
el.style.height = item.h + 'px';
el.style.zIndex = item.zIndex;
const wM = pxToM(item.w).toFixed(1);
const hM = pxToM(item.h).toFixed(1);
el.querySelector('.label').textContent = item.label;
el.querySelector('.dim-label').textContent = `${wM}m x ${hM}m${item.levels ? ' | ' + item.levels + 'L' : ''}`;
}
function selectItem(id) {
selectedItem = items.find(i => i.id === id) || null;
document.querySelectorAll('.grid-item').forEach(el => el.classList.remove('selected'));
if (selectedItem) {
const el = canvas.querySelector(`[data-id="${id}"]`);
if (el) el.classList.add('selected');
document.getElementById('tbSelected').textContent = selectedItem.label;
showProperties(selectedItem);
} else {
document.getElementById('tbSelected').textContent = 'None';
document.getElementById('propPanel').innerHTML = '<span style="color:#666">Select an item to edit</span>';
}
}
function showProperties(item) {
const info = TYPES[item.type];
const wM = pxToM(item.w).toFixed(1);
const hM = pxToM(item.h).toFixed(1);
const xM = pxToM(item.x).toFixed(1);
const yM = pxToM(item.y).toFixed(1);
const areaM2 = (pxToM(item.w) * pxToM(item.h)).toFixed(1);
let html = `
<div class="prop-row"><label>Type:</label><span style="color:${info.color};font-size:12px;">${info.label}</span></div>
<div class="prop-row"><label>Name:</label><input value="${item.label}" onchange="updateProp('label', this.value)"></div>
<div class="prop-row"><label>X:</label><input type="number" step="0.1" value="${xM}" onchange="updatePropM('x', this.value)"><span class="unit">m</span></div>
<div class="prop-row"><label>Y:</label><input type="number" step="0.1" value="${yM}" onchange="updatePropM('y', this.value)"><span class="unit">m</span></div>
<div class="prop-row"><label>Width:</label><input type="number" step="0.1" value="${wM}" onchange="updatePropM('w', this.value)"><span class="unit">m</span></div>
<div class="prop-row"><label>Depth:</label><input type="number" step="0.1" value="${hM}" onchange="updatePropM('h', this.value)"><span class="unit">m</span></div>
`;
if (info.isRack) {
html += `<div class="prop-row"><label>Levels:</label><input type="number" min="1" max="10" value="${item.levels||4}" onchange="updateProp('levels', parseInt(this.value))"></div>`;
}
html += `<div style="margin-top:8px; font-size:11px; color:#888;">Area: ${areaM2} m2</div>`;
document.getElementById('propPanel').innerHTML = html;
}
function updateProp(prop, value) {
if (!selectedItem) return;
selectedItem[prop] = value;
updateItemDOM(selectedItem);
showProperties(selectedItem);
updateStats();
}
function updatePropM(prop, valueM) {
if (!selectedItem) return;
selectedItem[prop] = mToPx(parseFloat(valueM));
updateItemDOM(selectedItem);
showProperties(selectedItem);
updateStats();
}
document.addEventListener('mousemove', function(e) {
if (dragging) {
const rect = canvas.getBoundingClientRect();
let nx = snap(e.clientX - rect.left - dragOffX);
let ny = snap(e.clientY - rect.top - dragOffY);
nx = Math.max(0, Math.min(nx, gridCols * cellSize - dragging.w));
ny = Math.max(0, Math.min(ny, gridRows * cellSize - dragging.h));
dragging.x = nx; dragging.y = ny;
updateItemDOM(dragging);
if (selectedItem && selectedItem.id === dragging.id) showProperties(dragging);
}
if (resizing) {
const rect = canvas.getBoundingClientRect();
let nw = snap(e.clientX - rect.left - resizing.x);
let nh = snap(e.clientY - rect.top - resizing.y);
nw = Math.max(cellSize, Math.min(nw, gridCols * cellSize - resizing.x));
nh = Math.max(cellSize, Math.min(nh, gridRows * cellSize - resizing.y));
resizing.w = nw; resizing.h = nh;
updateItemDOM(resizing);
if (selectedItem && selectedItem.id === resizing.id) showProperties(resizing);
}
});
document.addEventListener('mouseup', function() { dragging = null; resizing = null; updateStats(); });
function deleteItem(id) {
items = items.filter(i => i.id !== id);
const el = canvas.querySelector(`[data-id="${id}"]`);
if (el) el.remove();
if (selectedItem && selectedItem.id === id) selectItem(null);
updateStats();
}
function deleteSelectedItem() { if (selectedItem) deleteItem(selectedItem.id); hideContextMenu(); }
function duplicateItem() {
if (!selectedItem) return;
addItem(selectedItem.type, selectedItem.x + cellSize*2, selectedItem.y + cellSize*2, selectedItem.w, selectedItem.h, selectedItem.label, selectedItem.levels);
hideContextMenu();
}
function renameItem() {
if (!selectedItem) return; hideContextMenu();
const name = prompt('Enter new name:', selectedItem.label);
if (name) updateProp('label', name);
}
function bringToFront() {
if (!selectedItem) return;
selectedItem.zIndex = Math.max(...items.map(i => i.zIndex)) + 1;
updateItemDOM(selectedItem); hideContextMenu();
}
function sendToBack() {
if (!selectedItem) return;
selectedItem.zIndex = Math.min(...items.map(i => i.zIndex)) - 1;
updateItemDOM(selectedItem); hideContextMenu();
}
function showContextMenu(x, y) {
const menu = document.getElementById('contextMenu');
menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.style.display = 'block';
}
function hideContextMenu() { document.getElementById('contextMenu').style.display = 'none'; }
document.addEventListener('click', hideContextMenu);
canvas.addEventListener('contextmenu', e => e.preventDefault());
// Rack auto-place
function showRackAutoPlace() { document.getElementById('rackForm').style.display = 'flex'; }
function hideRackAutoPlace() { document.getElementById('rackForm').style.display = 'none'; }
function autoPlaceRacks() {
const rackWm = parseFloat(document.getElementById('rackW').value) || 2.7;
const rackDm = parseFloat(document.getElementById('rackD').value) || 1.0;
const type = document.getElementById('rackType').value;
const levels = parseInt(document.getElementById('rackLevels').value) || 4;
const rows = parseInt(document.getElementById('rackRows').value) || 5;
const bays = parseInt(document.getElementById('rackBays').value) || 8;
const aisleWm = parseFloat(document.getElementById('aisleW').value) || 3.0;
const startXm = parseFloat(document.getElementById('rackStartX').value) || 2.0;
const startYm = parseFloat(document.getElementById('rackStartY').value) || 3.0;
const bayWidthPx = mToPx(rackWm / bays);
const rackDepthPx = mToPx(rackDm);
const doubleDepthPx = mToPx(rackDm * 2);
const aislePx = mToPx(aisleWm);
const rackWidthPx = mToPx(rackWm);
const rackType = type === 'double' ? 'rack-double' : 'rack-single';
const actualDepthPx = type === 'double' ? doubleDepthPx : rackDepthPx;
const rowPitch = actualDepthPx + aislePx;
for (let r = 0; r < rows; r++) {
const x = mToPx(startXm);
const y = mToPx(startYm) + r * rowPitch;
if (y + actualDepthPx > gridRows * cellSize) break;
if (x + rackWidthPx > gridCols * cellSize) break;
const label = type === 'double'
? `Rack Row ${String.fromCharCode(65 + r)} (B2B)`
: `Rack Row ${String.fromCharCode(65 + r)}`;
addItem(rackType, x, y, rackWidthPx, actualDepthPx, label, levels);
}
hideRackAutoPlace();
showToast(`${rows} rack rows placed!`);
}
// Stats
function updateStats() {
const totalM2 = whWidthM * whDepthM;
let usedM2 = 0, rackM2 = 0, storageM2 = 0, prodM2 = 0, logM2 = 0, aisleM2 = 0;
let rackCount = 0, totalLevels = 0;
items.forEach(item => {
const area = pxToM(item.w) * pxToM(item.h);
usedM2 += area;
const cat = TYPES[item.type]?.category;
if (cat === 'rack') { rackM2 += area; rackCount++; totalLevels += (item.levels || 4); }
else if (cat === 'storage') storageM2 += area;
else if (cat === 'production') prodM2 += area;
else if (cat === 'logistics') logM2 += area;
else if (cat === 'aisle') aisleM2 += area;
});
document.getElementById('statTotal').textContent = totalM2.toFixed(1) + ' m2';
document.getElementById('statUsed').textContent = usedM2.toFixed(1) + ' m2';
document.getElementById('statUtil').textContent = (totalM2 > 0 ? (usedM2 / totalM2 * 100).toFixed(1) : 0) + '%';
document.getElementById('statRack').textContent = rackM2.toFixed(1) + ' m2';
document.getElementById('statRackCount').textContent = rackCount;
document.getElementById('statLevels').textContent = totalLevels;
document.getElementById('statStorage').textContent = storageM2.toFixed(1) + ' m2';
document.getElementById('statProd').textContent = prodM2.toFixed(1) + ' m2';
document.getElementById('statLogistics').textContent = logM2.toFixed(1) + ' m2';
document.getElementById('statAisle').textContent = aisleM2.toFixed(1) + ' m2';
document.getElementById('tbCount').textContent = items.length;
}
function buildLegend() {
const box = document.getElementById('legendBox');
box.innerHTML = '';
Object.entries(TYPES).forEach(([key, info]) => {
box.innerHTML += `<div class="legend-item"><div class="legend-dot" style="background:${info.color};"></div>${info.label}</div>`;
});
}
// Save/Load
function saveLayout() {
const data = { whWidthM, whDepthM, scaleM, cellSize, items: items.map(i => ({...i})) };
localStorage.setItem('warehouseLayout', JSON.stringify(data));
showToast('Layout saved!');
}
function loadLayout() {
const raw = localStorage.getItem('warehouseLayout');
if (!raw) { showToast('No saved layout found'); return; }
importData(JSON.parse(raw));
showToast('Layout loaded!');
}
function importData(data) {
whWidthM = data.whWidthM; whDepthM = data.whDepthM; scaleM = data.scaleM; cellSize = data.cellSize;
document.getElementById('whWidth').value = whWidthM;
document.getElementById('whDepth').value = whDepthM;
document.getElementById('scaleM').value = scaleM;
document.getElementById('cellSize').value = cellSize;
updateWarehouseSize();
canvas.querySelectorAll('.grid-item').forEach(el => el.remove());
items = []; nextId = 1;
data.items.forEach(i => {
const item = addItem(i.type, i.x, i.y, i.w, i.h, i.label, i.levels);
item.zIndex = i.zIndex || item.zIndex;
updateItemDOM(item);
});
}
function exportJSON() {
const data = { whWidthM, whDepthM, scaleM, cellSize, items: items.map(i => ({...i})) };
document.getElementById('modalTitle').textContent = 'Export / Import JSON';
document.getElementById('modalText').value = JSON.stringify(data, null, 2);
document.getElementById('jsonModal').classList.add('show');
}
function importFromModal() {
try {
importData(JSON.parse(document.getElementById('modalText').value));
closeModal(); showToast('Layout imported!');
} catch (e) { showToast('Invalid JSON'); }
}
function copyModalText() {
document.getElementById('modalText').select(); document.execCommand('copy');
showToast('Copied!');
}
function closeModal() { document.getElementById('jsonModal').classList.remove('show'); }
function exportImage() {
const w = window.open('', '_blank', `width=${gridCols*cellSize+100},height=${gridRows*cellSize+120}`);
w.document.write(`<html><head><title>Warehouse Layout</title><style>
body{margin:20px;background:#1a1a2e;font-family:sans-serif;}
.canvas{position:relative;width:${gridCols*cellSize}px;height:${gridRows*cellSize}px;border:2px solid #0f3460;background:#1a1a2e;
background-image:linear-gradient(to right,rgba(255,255,255,0.04) 1px,transparent 1px),linear-gradient(to bottom,rgba(255,255,255,0.04) 1px,transparent 1px);
background-size:${cellSize}px ${cellSize}px;}
.item{position:absolute;border-radius:4px;display:flex;align-items:center;justify-content:center;flex-direction:column;font-size:11px;font-weight:600;color:rgba(255,255,255,0.9);text-shadow:0 1px 2px rgba(0,0,0,0.5);border:2px solid rgba(255,255,255,0.15);}
.dim{font-size:9px;color:rgba(255,255,255,0.5);}
h3{color:#e94560;margin-bottom:8px;}
.info{color:#888;font-size:12px;margin-top:8px;}
</style></head><body><h3>Factory Warehouse Layout (${whWidthM}m x ${whDepthM}m) | Scale: 1 cell = ${scaleM}m</h3><div class="canvas">`);
items.forEach(item => {
const info = TYPES[item.type];
const wm = pxToM(item.w).toFixed(1), hm = pxToM(item.h).toFixed(1);
w.document.write(`<div class="item" style="left:${item.x}px;top:${item.y}px;width:${item.w}px;height:${item.h}px;background:${info.color};z-index:${item.zIndex};">${item.label}<span class="dim">${wm}m x ${hm}m${item.levels ? ' | '+item.levels+'L' : ''}</span></div>`);
});
w.document.write('</div><p class="info">Ctrl+P to save as PDF</p></body></html>');
w.document.close();
}
function clearAll() {
if (!confirm('Clear all items?')) return;
canvas.querySelectorAll('.grid-item').forEach(el => el.remove());
items = []; selectItem(null); updateStats();
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
// Keyboard
document.addEventListener('keydown', function(e) {
if (e.key === 'Delete' && selectedItem) deleteItem(selectedItem.id);
if (e.key === 'Escape') {
selectedType = null;
document.querySelectorAll('.element-btn').forEach(b => b.classList.remove('active'));
selectItem(null);
}
if (selectedItem && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) {
e.preventDefault();
const step = e.shiftKey ? cellSize * 4 : cellSize;
if (e.key === 'ArrowLeft') selectedItem.x = Math.max(0, selectedItem.x - step);
if (e.key === 'ArrowRight') selectedItem.x = Math.min(gridCols * cellSize - selectedItem.w, selectedItem.x + step);
if (e.key === 'ArrowUp') selectedItem.y = Math.max(0, selectedItem.y - step);
if (e.key === 'ArrowDown') selectedItem.y = Math.min(gridRows * cellSize - selectedItem.h, selectedItem.y + step);
updateItemDOM(selectedItem);
showProperties(selectedItem);
}
});
canvas.addEventListener('mousedown', function(e) { if (e.target === canvas) selectItem(null); });
// Init
updateWarehouseSize();
buildLegend();
</script>
</body>
</html>
```
### 2. warehouse-simulation.html (기본 시뮬레이션)
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warehouse Simulation</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #0d1117; color: #eee; display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 300px; background: #161b22; padding: 16px; display: flex; flex-direction: column; gap: 10px;
border-right: 2px solid #30363d; overflow-y: auto;
}
.sidebar h2 { font-size: 18px; color: #58a6ff; }
.sidebar h3 { font-size: 12px; color: #8b949e; margin-top: 8px; text-transform: uppercase; letter-spacing: 1px; }
.param-row { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
.param-row label { font-size: 12px; color: #8b949e; width: 120px; }
.param-row input, .param-row select {
width: 80px; padding: 4px 6px; background: #0d1117; border: 1px solid #30363d;
border-radius: 4px; color: #eee; font-size: 12px; text-align: center;
}
.param-row .unit { font-size: 11px; color: #484f58; width: 30px; }
.btn {
padding: 10px 16px; border: 1px solid #30363d; border-radius: 8px; background: #21262d;
color: #c9d1d9; cursor: pointer; font-size: 13px; transition: all 0.2s; text-align: center; font-weight: 600;
}
.btn:hover { border-color: #58a6ff; color: #fff; }
.btn.primary { background: #238636; border-color: #238636; color: #fff; }
.btn.primary:hover { background: #2ea043; }
.btn.danger { background: #da3633; border-color: #da3633; color: #fff; }
.btn.danger:hover { background: #f85149; }
.btn.active { background: #1f6feb; border-color: #1f6feb; }
.btn-row { display: flex; gap: 6px; }
.btn-row .btn { flex: 1; }
.main { flex: 1; display: flex; flex-direction: column; }
.toolbar {
height: 52px; background: #161b22; display: flex; align-items: center; padding: 0 16px; gap: 16px;
border-bottom: 2px solid #30363d;
}
.time-display {
background: #0d1117; padding: 6px 16px; border-radius: 8px; border: 1px solid #30363d;
font-family: 'Consolas', monospace; font-size: 16px; color: #58a6ff; font-weight: 700; min-width: 160px; text-align: center;
}
.speed-badge { font-size: 12px; color: #8b949e; background: #21262d; padding: 4px 10px; border-radius: 12px; }
.canvas-area {
flex: 1; overflow: auto; position: relative; background: #0d1117;
}
.canvas {
position: relative; margin: 30px;
background-image: linear-gradient(to right, rgba(48,54,61,0.3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(48,54,61,0.3) 1px, transparent 1px);
}
/* Zones */
.zone {
position: absolute; border-radius: 6px; display: flex; align-items: center; justify-content: center;
flex-direction: column; font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.85);
text-shadow: 0 1px 3px rgba(0,0,0,0.6); user-select: none; border: 2px solid rgba(255,255,255,0.1);
transition: opacity 0.3s;
}
.zone .zone-label { pointer-events: none; }
.zone .zone-dim { font-size: 9px; color: rgba(255,255,255,0.4); pointer-events: none; }
.zone .fill-bar {
position: absolute; bottom: 0; left: 0; right: 0; border-radius: 0 0 4px 4px;
transition: height 0.5s, background 0.5s;
}
.zone .fill-text {
position: absolute; bottom: 4px; right: 6px; font-size: 10px; font-weight: 700;
color: rgba(255,255,255,0.8); pointer-events: none;
}
/* Forklift */
.forklift {
position: absolute; width: 16px; height: 16px; border-radius: 50%; z-index: 200;
display: flex; align-items: center; justify-content: center; font-size: 8px;
transition: left 0.3s linear, top 0.3s linear; pointer-events: none;
box-shadow: 0 0 8px rgba(255,255,255,0.3);
}
.forklift.carrying { box-shadow: 0 0 12px rgba(255,200,0,0.6); }
.forklift-label {
position: absolute; top: -14px; left: 50%; transform: translateX(-50%);
font-size: 8px; color: rgba(255,255,255,0.6); white-space: nowrap; pointer-events: none;
}
/* Trail */
.trail-dot {
position: absolute; width: 4px; height: 4px; border-radius: 50%; opacity: 0.3;
pointer-events: none; z-index: 150;
}
/* Heatmap overlay */
.heat-cell {
position: absolute; pointer-events: none; z-index: 100; transition: background 0.5s;
}
/* Right panel */
.right-panel {
width: 280px; background: #161b22; border-left: 2px solid #30363d; padding: 16px;
display: flex; flex-direction: column; gap: 10px; overflow-y: auto;
}
.right-panel h3 { font-size: 13px; color: #58a6ff; }
.stat-card {
background: #0d1117; border: 1px solid #30363d; border-radius: 10px; padding: 12px;
}
.stat-card .stat-title { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .stat-value { font-size: 24px; font-weight: 700; color: #58a6ff; margin-top: 2px; }
.stat-card .stat-sub { font-size: 11px; color: #484f58; }
.stat-row { display: flex; gap: 8px; }
.stat-row .stat-card { flex: 1; }
.mini-chart { width: 100%; height: 60px; position: relative; margin-top: 6px; }
.mini-chart canvas { width: 100%; height: 100%; }
.log-box {
background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 8px;
font-size: 11px; font-family: 'Consolas', monospace; color: #8b949e;
height: 150px; overflow-y: auto;
}
.log-box .log-entry { padding: 2px 0; border-bottom: 1px solid #21262d; }
.log-box .log-time { color: #484f58; }
.log-box .log-in { color: #3fb950; }
.log-box .log-out { color: #f0883e; }
.log-box .log-move { color: #58a6ff; }
/* Progress bars */
.progress-bar { width: 100%; height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; margin-top: 4px; }
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.5s, background 0.3s; }
.legend-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #8b949e; margin-top: 2px; }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
</style>
</head>
<body>
<div class="sidebar">
<h2>Warehouse Simulation</h2>
<h3>Warehouse</h3>
<div class="param-row"><label>Width:</label><input id="whW" type="number" value="40" step="1"><span class="unit">m</span></div>
<div class="param-row"><label>Depth:</label><input id="whD" type="number" value="25" step="1"><span class="unit">m</span></div>
<h3>Rack Configuration</h3>
<div class="param-row"><label>Rack rows:</label><input id="rackRows" type="number" value="5" min="1" max="15"></div>
<div class="param-row"><label>Bays per row:</label><input id="rackBays" type="number" value="10" min="1" max="30"></div>
<div class="param-row"><label>Rack levels:</label><input id="rackLevels" type="number" value="4" min="1" max="8"></div>
<div class="param-row"><label>Rack width:</label><input id="rackW" type="number" value="12" step="0.5"><span class="unit">m</span></div>
<div class="param-row"><label>Rack depth:</label><input id="rackDepth" type="number" value="2.0" step="0.1"><span class="unit">m</span></div>
<div class="param-row"><label>Aisle width:</label><input id="aisleW" type="number" value="3.0" step="0.1"><span class="unit">m</span></div>
<h3>Operations</h3>
<div class="param-row"><label>Forklifts:</label><input id="numForklifts" type="number" value="4" min="1" max="12"></div>
<div class="param-row"><label>Forklift speed:</label><input id="forkSpeed" type="number" value="2.0" step="0.1"><span class="unit">m/s</span></div>
<div class="param-row"><label>Inbound rate:</label><input id="inRate" type="number" value="30" min="1"><span class="unit">/hr</span></div>
<div class="param-row"><label>Outbound rate:</label><input id="outRate" type="number" value="25" min="1"><span class="unit">/hr</span></div>
<div class="param-row"><label>Pick time:</label><input id="pickTime" type="number" value="15" min="1"><span class="unit">sec</span></div>
<div class="param-row"><label>Load time:</label><input id="loadTime" type="number" value="20" min="1"><span class="unit">sec</span></div>
<h3>Simulation</h3>
<div class="param-row"><label>Duration:</label><input id="simDuration" type="number" value="8" min="1" max="24"><span class="unit">hrs</span></div>
<div class="param-row">
<label>Speed:</label>
<select id="simSpeed" style="width:80px;">
<option value="1">1x</option>
<option value="5">5x</option>
<option value="10" selected>10x</option>
<option value="30">30x</option>
<option value="60">60x</option>
<option value="120">120x</option>
</select>
</div>
<h3>Display</h3>
<div class="btn-row">
<button class="btn" id="btnHeat" onclick="toggleHeatmap()">Heatmap</button>
<button class="btn" id="btnTrail" onclick="toggleTrails()">Trails</button>
</div>
<div style="margin-top: 10px;"></div>
<button class="btn primary" id="btnStart" onclick="startSim()">Start Simulation</button>
<div class="btn-row">
<button class="btn" id="btnPause" onclick="pauseSim()" disabled>Pause</button>
<button class="btn danger" id="btnReset" onclick="resetSim()">Reset</button>
</div>
</div>
<div class="main">
<div class="toolbar">
<div class="time-display" id="timeDisplay">00:00:00</div>
<span class="speed-badge" id="speedBadge">10x speed</span>
<span style="color:#8b949e; font-size:12px;" id="statusText">Ready</span>
</div>
<div class="canvas-area">
<div class="canvas" id="canvas"></div>
</div>
</div>
<div class="right-panel">
<h3>Real-time Dashboard</h3>
<div class="stat-row">
<div class="stat-card">
<div class="stat-title">Inbound</div>
<div class="stat-value" id="valInbound">0</div>
<div class="stat-sub" id="valInRate">0 /hr</div>
</div>
<div class="stat-card">
<div class="stat-title">Outbound</div>
<div class="stat-value" id="valOutbound">0</div>
<div class="stat-sub" id="valOutRate">0 /hr</div>
</div>
</div>
<div class="stat-card">
<div class="stat-title">Rack Occupancy</div>
<div class="stat-value" id="valOccupancy">0%</div>
<div class="progress-bar"><div class="progress-fill" id="occBar" style="width:0%; background:#238636;"></div></div>
<div class="stat-sub"><span id="valSlotsFilled">0</span> / <span id="valSlotsTotal">0</span> slots</div>
</div>
<div class="stat-card">
<div class="stat-title">Forklift Utilization</div>
<div class="stat-value" id="valForkUtil">0%</div>
<div class="progress-bar"><div class="progress-fill" id="forkBar" style="width:0%; background:#1f6feb;"></div></div>
<div id="forkList" style="margin-top:6px;"></div>
</div>
<div class="stat-card">
<div class="stat-title">Throughput (per hour)</div>
<div class="mini-chart"><canvas id="chartCanvas"></canvas></div>
</div>
<div class="stat-card">
<div class="stat-title">Bottleneck Analysis</div>
<div id="bottleneckInfo" style="font-size:12px; color:#8b949e; margin-top:4px;">Waiting for data...</div>
</div>
<h3>Activity Log</h3>
<div class="log-box" id="logBox"></div>
<h3>Legend</h3>
<div class="legend-row"><div class="legend-dot" style="background:#d35400;"></div> Rack (Selective)</div>
<div class="legend-row"><div class="legend-dot" style="background:#1abc9c;"></div> Receiving Dock</div>
<div class="legend-row"><div class="legend-dot" style="background:#16a085;"></div> Shipping Dock</div>
<div class="legend-row"><div class="legend-dot" style="background:#8e44ad;"></div> QC / Staging</div>
<div class="legend-row"><div class="legend-dot" style="background:#3498db;"></div> Forklift (idle)</div>
<div class="legend-row"><div class="legend-dot" style="background:#f0883e;"></div> Forklift (carrying)</div>
</div>
<script>
// ===== CONFIG =====
const CELL = 20; // px per meter
let simRunning = false, simPaused = false, simTimer = null;
let simTime = 0; // seconds
let showHeatmap = false, showTrails = false;
// ===== LAYOUT GENERATION =====
let zones = [];
let rackSlots = []; // { rackIdx, level, bay, filled }
let forklifts = [];
let heatGrid = [];
// Throughput tracking
let inboundCount = 0, outboundCount = 0;
let hourlyIn = [], hourlyOut = [];
let inThisHour = 0, outThisHour = 0;
let lastHour = 0;
let pendingInbound = 0, pendingOutbound = 0;
const canvas = document.getElementById('canvas');
function buildLayout() {
canvas.innerHTML = '';
zones = []; rackSlots = [];
const whW = parseInt(document.getElementById('whW').value) || 40;
const whD = parseInt(document.getElementById('whD').value) || 25;
const rackRows = parseInt(document.getElementById('rackRows').value) || 5;
const rackBays = parseInt(document.getElementById('rackBays').value) || 10;
const rackLevels = parseInt(document.getElementById('rackLevels').value) || 4;
const rackW = parseFloat(document.getElementById('rackW').value) || 12;
const rackDepth = parseFloat(document.getElementById('rackDepth').value) || 2.0;
const aisleW = parseFloat(document.getElementById('aisleW').value) || 3.0;
canvas.style.width = whW * CELL + 'px';
canvas.style.height = whD * CELL + 'px';
canvas.style.backgroundSize = CELL + 'px ' + CELL + 'px';
// Receiving dock (left side)
zones.push({ id: 'receiving', type: 'dock-in', label: 'Receiving Dock', color: '#1abc9c',
x: 0, y: 3, w: 3, h: whD - 6 });
// Shipping dock (right side)
zones.push({ id: 'shipping', type: 'dock-out', label: 'Shipping Dock', color: '#16a085',
x: whW - 3, y: 3, w: 3, h: whD - 6 });
// QC / Staging (near receiving)
zones.push({ id: 'staging-in', type: 'staging', label: 'Inbound Staging', color: '#8e44ad',
x: 4, y: 2, w: 3, h: whD - 4 });
// Outbound staging (near shipping)
zones.push({ id: 'staging-out', type: 'staging', label: 'Outbound Staging', color: '#8e44ad',
x: whW - 7, y: 2, w: 3, h: whD - 4 });
// Racks
const rackStartX = 9;
const rackStartY = 2;
const rackAreaW = whW - 18;
for (let r = 0; r < rackRows; r++) {
const ry = rackStartY + r * (rackDepth + aisleW);
if (ry + rackDepth > whD - 1) break;
const actualW = Math.min(rackW, rackAreaW);
const rx = rackStartX + (rackAreaW - actualW) / 2;
const rackIdx = zones.length;
zones.push({
id: `rack-${r}`, type: 'rack', label: `Rack Row ${String.fromCharCode(65 + r)}`,
color: '#d35400', x: rx, y: ry, w: actualW, h: rackDepth,
levels: rackLevels, bays: rackBays, fillLevel: 0.3 // start 30% full
});
// Create slots
for (let l = 0; l < rackLevels; l++) {
for (let b = 0; b < rackBays; b++) {
rackSlots.push({ rackIdx, level: l, bay: b, filled: Math.random() < 0.3 });
}
}
}
// Render zones
zones.forEach((z, i) => {
const el = document.createElement('div');
el.className = 'zone';
el.id = 'zone-' + z.id;
el.style.left = z.x * CELL + 'px';
el.style.top = z.y * CELL + 'px';
el.style.width = z.w * CELL + 'px';
el.style.height = z.h * CELL + 'px';
el.style.background = z.color;
let inner = `<span class="zone-label">${z.label}</span>`;
inner += `<span class="zone-dim">${z.w}m x ${z.h}m</span>`;
if (z.type === 'rack') {
inner += `<div class="fill-bar" id="fill-${z.id}" style="height:30%; background:rgba(0,0,0,0.4);"></div>`;
inner += `<span class="fill-text" id="filltext-${z.id}">30%</span>`;
}
el.innerHTML = inner;
canvas.appendChild(el);
});
// Init heatmap grid
heatGrid = [];
for (let gy = 0; gy < whD; gy++) {
heatGrid[gy] = [];
for (let gx = 0; gx < whW; gx++) {
heatGrid[gy][gx] = 0;
}
}
updateSlotStats();
}
// ===== FORKLIFTS =====
function createForklifts() {
document.querySelectorAll('.forklift').forEach(e => e.remove());
forklifts = [];
const n = parseInt(document.getElementById('numForklifts').value) || 4;
const whD = parseInt(document.getElementById('whD').value) || 25;
const colors = ['#58a6ff','#3fb950','#d2a8ff','#f778ba','#ffa657','#79c0ff','#7ee787','#ff7b72','#e3b341','#a5d6ff','#ffc680','#d73a49'];
for (let i = 0; i < n; i++) {
const fl = {
id: i, color: colors[i % colors.length],
x: 5, y: 3 + (i * 3) % (whD - 6),
state: 'idle', // idle, moving-to-pick, picking, moving-to-drop, dropping
task: null, carrying: false,
targetX: 0, targetY: 0,
busyUntil: 0, busyTime: 0, totalTime: 0,
path: [], pathIdx: 0,
};
forklifts.push(fl);
const el = document.createElement('div');
el.className = 'forklift';
el.id = 'fork-' + i;
el.style.left = fl.x * CELL + 'px';
el.style.top = fl.y * CELL + 'px';
el.style.background = fl.color;
el.innerHTML = `<span class="forklift-label">F${i+1}</span>`;
canvas.appendChild(el);
}
}
function assignTask(fl) {
// Decide: inbound or outbound task
const doInbound = pendingInbound > 0 || (pendingOutbound <= 0 && Math.random() < 0.55);
const racks = zones.filter(z => z.type === 'rack');
if (racks.length === 0) return;
if (doInbound && pendingInbound > 0) {
pendingInbound--;
// Pick from receiving staging, drop at rack
const staging = zones.find(z => z.id === 'staging-in');
const rack = racks[Math.floor(Math.random() * racks.length)];
fl.task = 'inbound';
fl.path = [
{ x: staging.x + staging.w / 2, y: staging.y + Math.random() * staging.h, action: 'pick' },
{ x: rack.x + Math.random() * rack.w, y: rack.y + rack.h / 2, action: 'drop' },
];
fl.pathIdx = 0;
fl.state = 'moving-to-pick';
fl.carrying = false;
setTarget(fl, fl.path[0]);
} else if (pendingOutbound > 0) {
pendingOutbound--;
const rack = racks[Math.floor(Math.random() * racks.length)];
const staging = zones.find(z => z.id === 'staging-out');
fl.task = 'outbound';
fl.path = [
{ x: rack.x + Math.random() * rack.w, y: rack.y + rack.h / 2, action: 'pick' },
{ x: staging.x + staging.w / 2, y: staging.y + Math.random() * staging.h, action: 'drop' },
];
fl.pathIdx = 0;
fl.state = 'moving-to-pick';
fl.carrying = false;
setTarget(fl, fl.path[0]);
} else {
fl.state = 'idle';
}
}
function setTarget(fl, point) {
fl.targetX = point.x;
fl.targetY = point.y;
}
// ===== SIMULATION LOOP =====
function simTick(dtReal) {
const speed = parseInt(document.getElementById('simSpeed').value) || 10;
const dt = dtReal * speed; // simulated seconds
const duration = (parseInt(document.getElementById('simDuration').value) || 8) * 3600;
const forkSpeed = parseFloat(document.getElementById('forkSpeed').value) || 2.0;
const pickTimeSec = parseInt(document.getElementById('pickTime').value) || 15;
const loadTimeSec = parseInt(document.getElementById('loadTime').value) || 20;
const inRatePerSec = (parseInt(document.getElementById('inRate').value) || 30) / 3600;
const outRatePerSec = (parseInt(document.getElementById('outRate').value) || 25) / 3600;
simTime += dt;
if (simTime >= duration) { stopSim(); return; }
// Generate tasks
pendingInbound += inRatePerSec * dt;
pendingOutbound += outRatePerSec * dt;
// Current hour
const curHour = Math.floor(simTime / 3600);
if (curHour > lastHour) {
hourlyIn.push(inThisHour);
hourlyOut.push(outThisHour);
inThisHour = 0; outThisHour = 0;
lastHour = curHour;
}
// Update forklifts
forklifts.forEach(fl => {
fl.totalTime += dt;
if (fl.state === 'idle') {
assignTask(fl);
return;
}
if (fl.state === 'busy') {
if (simTime >= fl.busyUntil) {
fl.busyTime += (fl.busyUntil - (simTime - dt) > 0 ? fl.busyUntil - (simTime - dt) : 0);
// Finished action
const point = fl.path[fl.pathIdx];
if (point.action === 'pick') {
fl.carrying = true;
fl.pathIdx++;
if (fl.pathIdx < fl.path.length) {
fl.state = 'moving-to-drop';
setTarget(fl, fl.path[fl.pathIdx]);
}
} else if (point.action === 'drop') {
fl.carrying = false;
// Complete task
if (fl.task === 'inbound') {
inboundCount++; inThisHour++;
fillRandomSlot(true);
addLog('in', `F${fl.id+1} stored pallet (Rack)`);
} else {
outboundCount++; outThisHour++;
fillRandomSlot(false);
addLog('out', `F${fl.id+1} shipped pallet`);
}
fl.state = 'idle';
fl.task = null;
}
} else {
fl.busyTime += dt;
}
return;
}
// Moving
const dx = fl.targetX - fl.x;
const dy = fl.targetY - fl.y;
const dist = Math.sqrt(dx*dx + dy*dy);
const moveStep = forkSpeed * dt;
if (dist <= moveStep + 0.1) {
fl.x = fl.targetX;
fl.y = fl.targetY;
fl.busyTime += dt;
// Arrived
const point = fl.path[fl.pathIdx];
const waitTime = point.action === 'pick' ? pickTimeSec : loadTimeSec;
fl.state = 'busy';
fl.busyUntil = simTime + waitTime;
addLog('move', `F${fl.id+1} arrived at ${point.action} point`);
} else {
fl.x += (dx / dist) * moveStep;
fl.y += (dy / dist) * moveStep;
fl.busyTime += dt;
}
// Heatmap
const gx = Math.floor(fl.x);
const gy = Math.floor(fl.y);
if (heatGrid[gy] && heatGrid[gy][gx] !== undefined) {
heatGrid[gy][gx] += dt;
}
});
updateDisplay();
}
function fillRandomSlot(fill) {
const empties = rackSlots.filter(s => fill ? !s.filled : s.filled);
if (empties.length > 0) {
empties[Math.floor(Math.random() * empties.length)].filled = fill;
}
}
// ===== DISPLAY =====
function updateDisplay() {
// Time
const h = Math.floor(simTime / 3600);
const m = Math.floor((simTime % 3600) / 60);
const s = Math.floor(simTime % 60);
document.getElementById('timeDisplay').textContent =
String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
// Forklifts
forklifts.forEach(fl => {
const el = document.getElementById('fork-' + fl.id);
if (!el) return;
el.style.left = (fl.x * CELL - 8) + 'px';
el.style.top = (fl.y * CELL - 8) + 'px';
el.className = 'forklift' + (fl.carrying ? ' carrying' : '');
el.style.background = fl.carrying ? '#f0883e' : fl.color;
// Trail
if (showTrails && (fl.state === 'moving-to-pick' || fl.state === 'moving-to-drop')) {
const dot = document.createElement('div');
dot.className = 'trail-dot';
dot.style.left = (fl.x * CELL) + 'px';
dot.style.top = (fl.y * CELL) + 'px';
dot.style.background = fl.color;
canvas.appendChild(dot);
setTimeout(() => dot.remove(), 5000);
}
});
// Stats
document.getElementById('valInbound').textContent = inboundCount;
document.getElementById('valOutbound').textContent = outboundCount;
const hrs = Math.max(simTime / 3600, 0.01);
document.getElementById('valInRate').textContent = (inboundCount / hrs).toFixed(1) + ' /hr';
document.getElementById('valOutRate').textContent = (outboundCount / hrs).toFixed(1) + ' /hr';
// Slots
updateSlotStats();
// Forklift util
let totalUtil = 0;
let forkHtml = '';
forklifts.forEach(fl => {
const util = fl.totalTime > 0 ? (fl.busyTime / fl.totalTime * 100) : 0;
totalUtil += util;
const stateEmoji = fl.state === 'idle' ? 'IDLE' : fl.carrying ? 'CARRY' : 'MOVE';
forkHtml += `<div class="legend-row"><div class="legend-dot" style="background:${fl.color}"></div>F${fl.id+1}: ${util.toFixed(0)}% — ${stateEmoji}</div>`;
});
const avgUtil = forklifts.length > 0 ? totalUtil / forklifts.length : 0;
document.getElementById('valForkUtil').textContent = avgUtil.toFixed(0) + '%';
document.getElementById('forkBar').style.width = avgUtil + '%';
document.getElementById('forkBar').style.background = avgUtil > 80 ? '#da3633' : avgUtil > 50 ? '#d29922' : '#1f6feb';
document.getElementById('forkList').innerHTML = forkHtml;
// Rack fill visualization
const racks = zones.filter(z => z.type === 'rack');
racks.forEach((rack, ri) => {
const slots = rackSlots.filter(s => s.rackIdx === zones.indexOf(rack));
const filled = slots.filter(s => s.filled).length;
const pct = slots.length > 0 ? (filled / slots.length * 100) : 0;
rack.fillLevel = pct / 100;
const bar = document.getElementById('fill-' + rack.id);
const text = document.getElementById('filltext-' + rack.id);
if (bar) {
bar.style.height = pct + '%';
bar.style.background = pct > 90 ? 'rgba(218,54,51,0.5)' : pct > 70 ? 'rgba(210,169,34,0.4)' : 'rgba(0,0,0,0.4)';
}
if (text) text.textContent = pct.toFixed(0) + '%';
});
// Chart
drawChart();
// Bottleneck
updateBottleneck();
// Heatmap
if (showHeatmap) renderHeatmap();
}
function updateSlotStats() {
const filled = rackSlots.filter(s => s.filled).length;
const total = rackSlots.length;
const pct = total > 0 ? (filled / total * 100) : 0;
document.getElementById('valOccupancy').textContent = pct.toFixed(1) + '%';
document.getElementById('occBar').style.width = pct + '%';
document.getElementById('occBar').style.background = pct > 90 ? '#da3633' : pct > 70 ? '#d29922' : '#238636';
document.getElementById('valSlotsFilled').textContent = filled;
document.getElementById('valSlotsTotal').textContent = total;
}
function updateBottleneck() {
const inRate = parseInt(document.getElementById('inRate').value) || 30;
const outRate = parseInt(document.getElementById('outRate').value) || 25;
const n = forklifts.length;
const pickT = parseInt(document.getElementById('pickTime').value) || 15;
const loadT = parseInt(document.getElementById('loadTime').value) || 20;
const avgCycleTime = pickT + loadT + 30; // ~30s travel
const maxCapacity = Math.floor(n * 3600 / avgCycleTime);
const demand = inRate + outRate;
let html = '';
if (demand > maxCapacity * 0.9) {
html += `<div style="color:#f85149;">Forklift capacity near limit! Max ~${maxCapacity}/hr, demand ${demand}/hr</div>`;
}
const filled = rackSlots.filter(s => s.filled).length;
const pct = rackSlots.length > 0 ? filled / rackSlots.length : 0;
if (pct > 0.9) {
html += `<div style="color:#f85149;">Rack space critical! ${(pct*100).toFixed(0)}% full</div>`;
} else if (pct > 0.7) {
html += `<div style="color:#d29922;">Rack space warning: ${(pct*100).toFixed(0)}% full</div>`;
}
if (pendingInbound > n * 2) {
html += `<div style="color:#d29922;">Inbound queue building: ${Math.floor(pendingInbound)} waiting</div>`;
}
if (!html) html = '<div style="color:#3fb950;">All systems nominal</div>';
html += `<div style="margin-top:6px;color:#484f58;">Max forklift capacity: ~${maxCapacity}/hr</div>`;
document.getElementById('bottleneckInfo').innerHTML = html;
}
function drawChart() {
const c = document.getElementById('chartCanvas');
const ctx = c.getContext('2d');
const w = c.width = c.offsetWidth * 2;
const h = c.height = c.offsetHeight * 2;
ctx.clearRect(0, 0, w, h);
const allData = [...hourlyIn, inThisHour];
const allDataOut = [...hourlyOut, outThisHour];
const maxVal = Math.max(1, ...allData, ...allDataOut);
const barW = allData.length > 0 ? Math.min(w / allData.length - 2, 30) : 30;
allData.forEach((v, i) => {
const bh = (v / maxVal) * (h - 20);
const x = i * (barW + 4) + 4;
ctx.fillStyle = 'rgba(63,185,80,0.6)';
ctx.fillRect(x, h - bh - 10, barW / 2 - 1, bh);
const oh = (allDataOut[i] || 0) / maxVal * (h - 20);
ctx.fillStyle = 'rgba(240,136,62,0.6)';
ctx.fillRect(x + barW / 2, h - oh - 10, barW / 2 - 1, oh);
ctx.fillStyle = '#484f58';
ctx.font = '14px sans-serif';
ctx.fillText(i + 'h', x, h - 1);
});
// Legend
ctx.fillStyle = 'rgba(63,185,80,0.8)';
ctx.fillRect(w - 100, 4, 10, 10);
ctx.fillStyle = '#8b949e';
ctx.font = '14px sans-serif';
ctx.fillText('In', w - 86, 14);
ctx.fillStyle = 'rgba(240,136,62,0.8)';
ctx.fillRect(w - 60, 4, 10, 10);
ctx.fillStyle = '#8b949e';
ctx.fillText('Out', w - 46, 14);
}
function renderHeatmap() {
document.querySelectorAll('.heat-cell').forEach(e => e.remove());
const maxHeat = Math.max(1, ...heatGrid.flat());
const whW = parseInt(document.getElementById('whW').value) || 40;
const whD = parseInt(document.getElementById('whD').value) || 25;
for (let gy = 0; gy < whD; gy += 2) {
for (let gx = 0; gx < whW; gx += 2) {
const val = (heatGrid[gy]?.[gx] || 0) + (heatGrid[gy]?.[gx+1] || 0) +
(heatGrid[gy+1]?.[gx] || 0) + (heatGrid[gy+1]?.[gx+1] || 0);
if (val < 0.1) continue;
const intensity = Math.min(val / maxHeat, 1);
const el = document.createElement('div');
el.className = 'heat-cell';
el.style.left = gx * CELL + 'px';
el.style.top = gy * CELL + 'px';
el.style.width = CELL * 2 + 'px';
el.style.height = CELL * 2 + 'px';
const r = Math.floor(255 * intensity);
const g = Math.floor(100 * (1 - intensity));
el.style.background = `rgba(${r},${g},0,${intensity * 0.4})`;
canvas.appendChild(el);
}
}
}
function addLog(type, msg) {
const box = document.getElementById('logBox');
const h = Math.floor(simTime / 3600);
const m = Math.floor((simTime % 3600) / 60);
const timeStr = String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0');
const cls = type === 'in' ? 'log-in' : type === 'out' ? 'log-out' : 'log-move';
box.innerHTML = `<div class="log-entry"><span class="log-time">[${timeStr}]</span> <span class="${cls}">${msg}</span></div>` + box.innerHTML;
// Keep last 100
while (box.children.length > 100) box.removeChild(box.lastChild);
}
// ===== CONTROLS =====
function startSim() {
if (simRunning && simPaused) {
simPaused = false;
document.getElementById('btnPause').textContent = 'Pause';
document.getElementById('statusText').textContent = 'Running...';
runLoop();
return;
}
resetSim();
buildLayout();
createForklifts();
simRunning = true; simPaused = false;
document.getElementById('btnStart').disabled = true;
document.getElementById('btnPause').disabled = false;
document.getElementById('statusText').textContent = 'Running...';
document.getElementById('speedBadge').textContent = document.getElementById('simSpeed').value + 'x speed';
addLog('move', 'Simulation started');
runLoop();
}
let lastFrameTime = 0;
function runLoop() {
if (!simRunning || simPaused) return;
const now = performance.now();
if (lastFrameTime === 0) lastFrameTime = now;
const dtReal = Math.min((now - lastFrameTime) / 1000, 0.1); // cap at 100ms
lastFrameTime = now;
simTick(dtReal);
simTimer = requestAnimationFrame(runLoop);
}
function pauseSim() {
if (!simRunning) return;
simPaused = !simPaused;
document.getElementById('btnPause').textContent = simPaused ? 'Resume' : 'Pause';
document.getElementById('statusText').textContent = simPaused ? 'Paused' : 'Running...';
if (!simPaused) {
lastFrameTime = performance.now();
runLoop();
}
}
function stopSim() {
simRunning = false; simPaused = false;
if (simTimer) cancelAnimationFrame(simTimer);
document.getElementById('btnStart').disabled = false;
document.getElementById('btnPause').disabled = true;
document.getElementById('statusText').textContent = 'Completed';
addLog('move', 'Simulation completed');
// Final hour
hourlyIn.push(inThisHour);
hourlyOut.push(outThisHour);
drawChart();
}
function resetSim() {
simRunning = false; simPaused = false;
if (simTimer) cancelAnimationFrame(simTimer);
simTime = 0; lastFrameTime = 0;
inboundCount = 0; outboundCount = 0;
hourlyIn = []; hourlyOut = [];
inThisHour = 0; outThisHour = 0; lastHour = 0;
pendingInbound = 0; pendingOutbound = 0;
document.getElementById('btnStart').disabled = false;
document.getElementById('btnPause').disabled = true;
document.getElementById('statusText').textContent = 'Ready';
document.getElementById('timeDisplay').textContent = '00:00:00';
document.getElementById('valInbound').textContent = '0';
document.getElementById('valOutbound').textContent = '0';
document.getElementById('valInRate').textContent = '0 /hr';
document.getElementById('valOutRate').textContent = '0 /hr';
document.getElementById('logBox').innerHTML = '';
buildLayout();
createForklifts();
updateDisplay();
}
function toggleHeatmap() {
showHeatmap = !showHeatmap;
document.getElementById('btnHeat').classList.toggle('active', showHeatmap);
if (!showHeatmap) document.querySelectorAll('.heat-cell').forEach(e => e.remove());
}
function toggleTrails() {
showTrails = !showTrails;
document.getElementById('btnTrail').classList.toggle('active', showTrails);
if (!showTrails) document.querySelectorAll('.trail-dot').forEach(e => e.remove());
}
// Speed change
document.getElementById('simSpeed').addEventListener('change', function() {
document.getElementById('speedBadge').textContent = this.value + 'x speed';
});
// Init
buildLayout();
createForklifts();
</script>
</body>
</html>
```
### 3. warehouse-digital-twin.html (종합 디지털 트윈)
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warehouse Digital Twin</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',sans-serif;background:#0a0e17;color:#e0e0e0;display:flex;height:100vh;overflow:hidden}
/* Layout */
.left{width:260px;background:#111827;border-right:1px solid #1e293b;display:flex;flex-direction:column;overflow-y:auto;padding:12px;gap:8px}
.center{flex:1;display:flex;flex-direction:column}
.right{width:320px;background:#111827;border-left:1px solid #1e293b;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:10px}
/* Top bar */
.topbar{height:50px;background:#111827;border-bottom:1px solid #1e293b;display:flex;align-items:center;padding:0 16px;gap:16px}
.topbar h1{font-size:16px;color:#60a5fa;font-weight:700}
.clock{font-family:'Consolas',monospace;font-size:20px;color:#34d399;font-weight:700;background:#0a0e17;padding:6px 16px;border-radius:8px;border:1px solid #1e293b;min-width:120px;text-align:center}
.badge{font-size:11px;padding:4px 10px;border-radius:12px;font-weight:600}
.badge-speed{background:#1e3a5f;color:#60a5fa}
.badge-status{background:#064e3b;color:#34d399}
.badge-status.paused{background:#78350f;color:#fbbf24}
.badge-status.done{background:#1e293b;color:#94a3b8}
/* Canvas */
.canvas-wrap{flex:1;overflow:auto;background:#0a0e17}
.canvas{position:relative;margin:20px}
.canvas .grid-line-x,.canvas .grid-line-y{position:absolute;background:rgba(30,41,59,0.4)}
.canvas .ruler{position:absolute;font-size:9px;color:#475569;pointer-events:none}
/* Zones */
.zone{position:absolute;border-radius:4px;display:flex;align-items:center;justify-content:center;flex-direction:column;font-size:10px;font-weight:600;color:rgba(255,255,255,0.85);text-shadow:0 1px 2px rgba(0,0,0,0.6);user-select:none;border:1.5px solid rgba(255,255,255,0.08);overflow:hidden;transition:box-shadow .3s}
.zone .z-label{z-index:2;pointer-events:none;text-align:center;line-height:1.2}
.zone .z-dim{font-size:8px;color:rgba(255,255,255,0.35);z-index:2;pointer-events:none}
.zone .fill-overlay{position:absolute;bottom:0;left:0;right:0;transition:height .8s,background .5s;z-index:1}
.zone .fill-pct{position:absolute;bottom:3px;right:5px;font-size:9px;font-weight:700;z-index:3;pointer-events:none}
.zone.abc-a{box-shadow:inset 0 0 12px rgba(239,68,68,0.15)}
.zone.abc-b{box-shadow:inset 0 0 12px rgba(234,179,8,0.1)}
.zone.abc-c{box-shadow:inset 0 0 12px rgba(59,130,246,0.1)}
.zone .abc-tag{position:absolute;top:2px;left:4px;font-size:8px;font-weight:700;z-index:3;padding:1px 4px;border-radius:3px}
/* Aisle labels */
.aisle-label{position:absolute;font-size:8px;color:#334155;text-align:center;pointer-events:none;z-index:5;letter-spacing:1px}
/* Forklift */
.fk{position:absolute;width:14px;height:14px;border-radius:50%;z-index:200;pointer-events:none;transition:left .25s linear,top .25s linear;box-shadow:0 0 6px rgba(255,255,255,0.2)}
.fk.carry{box-shadow:0 0 10px rgba(251,191,36,0.6)}
.fk-id{position:absolute;top:-12px;left:50%;transform:translateX(-50%);font-size:7px;color:rgba(255,255,255,0.5);white-space:nowrap;pointer-events:none}
/* Heatmap */
.hcell{position:absolute;pointer-events:none;z-index:90;transition:background .8s}
/* Trail */
.trail{position:absolute;width:3px;height:3px;border-radius:50%;pointer-events:none;z-index:80;opacity:.25}
/* Sidebar */
.sec-title{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:1px;margin-top:6px;padding-bottom:4px;border-bottom:1px solid #1e293b}
.pr{display:flex;align-items:center;gap:4px;margin-top:3px}
.pr label{font-size:11px;color:#94a3b8;width:90px;flex-shrink:0}
.pr input,.pr select{width:65px;padding:3px 5px;background:#0a0e17;border:1px solid #1e293b;border-radius:4px;color:#e0e0e0;font-size:11px;text-align:center}
.pr .u{font-size:10px;color:#475569;width:25px}
.btn{padding:8px 12px;border:1px solid #1e293b;border-radius:6px;background:#1e293b;color:#94a3b8;cursor:pointer;font-size:12px;font-weight:600;transition:all .15s;text-align:center}
.btn:hover{border-color:#60a5fa;color:#fff}
.btn-go{background:#059669;border-color:#059669;color:#fff}
.btn-go:hover{background:#047857}
.btn-stop{background:#dc2626;border-color:#dc2626;color:#fff}
.btn-stop:hover{background:#b91c1c}
.btn-act{background:#1d4ed8;border-color:#1d4ed8;color:#fff}
.brow{display:flex;gap:4px}.brow .btn{flex:1}
/* Right panel cards */
.card{background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:10px}
.card-t{font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
.card-v{font-size:22px;font-weight:700;color:#60a5fa;margin-top:2px}
.card-s{font-size:10px;color:#475569;margin-top:1px}
.card-row{display:flex;gap:6px}.card-row .card{flex:1}
.pbar{width:100%;height:6px;background:#1e293b;border-radius:3px;overflow:hidden;margin-top:4px}
.pfill{height:100%;border-radius:3px;transition:width .5s,background .3s}
.chart-box{width:100%;height:80px;position:relative}.chart-box canvas{width:100%;height:100%}
.fk-list{font-size:10px;margin-top:4px}
.fk-row{display:flex;align-items:center;gap:4px;padding:2px 0;border-bottom:1px solid #1e293b}
.fk-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.fk-bar{flex:1;height:4px;background:#1e293b;border-radius:2px;overflow:hidden}
.fk-bar-fill{height:100%;border-radius:2px;transition:width .5s}
.log{background:#0a0e17;border:1px solid #1e293b;border-radius:6px;padding:6px;font-size:10px;font-family:'Consolas',monospace;color:#64748b;height:120px;overflow-y:auto}
.log .l-t{color:#334155}.log .l-i{color:#34d399}.log .l-o{color:#f97316}.log .l-w{color:#fbbf24}.log .l-e{color:#ef4444}
.bottleneck{font-size:11px;color:#94a3b8;line-height:1.5}
.bn-ok{color:#34d399}.bn-warn{color:#fbbf24}.bn-crit{color:#ef4444}
/* Tab */
.tabs{display:flex;gap:2px;margin-bottom:6px}
.tab{padding:5px 10px;font-size:11px;color:#64748b;cursor:pointer;border-radius:4px 4px 0 0;transition:all .15s}
.tab.active{background:#1e293b;color:#60a5fa;font-weight:600}
.tab-content{display:none}.tab-content.active{display:block}
</style>
</head>
<body>
<!-- LEFT PANEL -->
<div class="left">
<div style="font-size:15px;font-weight:700;color:#60a5fa">Digital Twin</div>
<div style="font-size:10px;color:#475569">Warehouse Simulation Engine</div>
<div class="sec-title">Warehouse</div>
<div class="pr"><label>Width:</label><input id="pWhW" type="number" value="50" step="1"><span class="u">m</span></div>
<div class="pr"><label>Depth:</label><input id="pWhD" type="number" value="30" step="1"><span class="u">m</span></div>
<div class="pr"><label>Ceiling H:</label><input id="pCeil" type="number" value="10" step="0.5"><span class="u">m</span></div>
<div class="sec-title">Rack (Selective)</div>
<div class="pr"><label>Rows:</label><input id="pRRows" type="number" value="6" min="1" max="20"></div>
<div class="pr"><label>Bays/Row:</label><input id="pRBays" type="number" value="12" min="1" max="40"></div>
<div class="pr"><label>Levels:</label><input id="pRLevels" type="number" value="4" min="1" max="8"></div>
<div class="pr"><label>Bay width:</label><input id="pBayW" type="number" value="2.7" step="0.1"><span class="u">m</span></div>
<div class="pr"><label>Rack depth:</label><input id="pRDepth" type="number" value="1.0" step="0.1"><span class="u">m</span></div>
<div class="pr"><label>Aisle width:</label><input id="pAisleW" type="number" value="3.5" step="0.1"><span class="u">m</span></div>
<div class="pr"><label>ABC ratio:</label><input id="pABC" type="text" value="20,30,50" style="width:65px"><span class="u">%</span></div>
<div class="sec-title">Resources</div>
<div class="pr"><label>Forklifts:</label><input id="pForks" type="number" value="5" min="1" max="15"></div>
<div class="pr"><label>Speed:</label><input id="pForkSpd" type="number" value="2.5" step="0.1"><span class="u">m/s</span></div>
<div class="pr"><label>Pick time:</label><input id="pPick" type="number" value="12"><span class="u">sec</span></div>
<div class="pr"><label>Put time:</label><input id="pPut" type="number" value="15"><span class="u">sec</span></div>
<div class="pr"><label>Level penalty:</label><input id="pLvlP" type="number" value="5"><span class="u">sec/L</span></div>
<div class="sec-title">Demand</div>
<div class="pr"><label>Inbound:</label><input id="pInRate" type="number" value="40"><span class="u">/hr</span></div>
<div class="pr"><label>Outbound:</label><input id="pOutRate" type="number" value="35"><span class="u">/hr</span></div>
<div class="pr"><label>Peak hours:</label><input id="pPeak" type="text" value="9,10,14,15" style="width:65px"></div>
<div class="pr"><label>Peak mult:</label><input id="pPeakM" type="number" value="1.8" step="0.1"><span class="u">x</span></div>
<div class="pr"><label>Init fill:</label><input id="pInitFill" type="number" value="45" min="0" max="100"><span class="u">%</span></div>
<div class="sec-title">Simulation</div>
<div class="pr"><label>Duration:</label><input id="pDur" type="number" value="8" min="1" max="24"><span class="u">hrs</span></div>
<div class="pr"><label>Speed:</label>
<select id="pSpeed">
<option value="1">1x</option><option value="5">5x</option><option value="10">10x</option>
<option value="30" selected>30x</option><option value="60">60x</option><option value="120">120x</option><option value="300">300x</option>
</select>
</div>
<div class="sec-title">Display</div>
<div class="brow">
<button class="btn" id="bHeat" onclick="toggleHeat()">Heatmap</button>
<button class="btn" id="bTrail" onclick="toggleTrail()">Trails</button>
</div>
<div style="margin-top:8px"></div>
<button class="btn btn-go" id="bStart" onclick="startSim()">Start Simulation</button>
<div class="brow">
<button class="btn" id="bPause" onclick="pauseSim()" disabled>Pause</button>
<button class="btn btn-stop" id="bReset" onclick="resetSim()">Reset</button>
</div>
<div class="sec-title" style="margin-top:12px">Scenario</div>
<div class="brow">
<button class="btn" onclick="scenario('base')">Base</button>
<button class="btn" onclick="scenario('peak')">Peak Load</button>
</div>
<div class="brow">
<button class="btn" onclick="scenario('addfork')">+2 Forklifts</button>
<button class="btn" onclick="scenario('dense')">Dense Rack</button>
</div>
</div>
<!-- CENTER -->
<div class="center">
<div class="topbar">
<h1>Warehouse Digital Twin</h1>
<div class="clock" id="clock">00:00:00</div>
<span class="badge badge-speed" id="bSpeed">30x</span>
<span class="badge badge-status" id="bStatus">Ready</span>
<span style="flex:1"></span>
<span style="font-size:11px;color:#475569" id="whInfo">50m x 30m</span>
</div>
<div class="canvas-wrap">
<div class="canvas" id="cv"></div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="right">
<div class="tabs">
<div class="tab active" onclick="switchTab('dashboard')">Dashboard</div>
<div class="tab" onclick="switchTab('analysis')">Analysis</div>
<div class="tab" onclick="switchTab('log')">Log</div>
</div>
<!-- Dashboard tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="card-row">
<div class="card"><div class="card-t">Inbound</div><div class="card-v" id="vIn">0</div><div class="card-s" id="vInR">0/hr</div></div>
<div class="card"><div class="card-t">Outbound</div><div class="card-v" id="vOut">0</div><div class="card-s" id="vOutR">0/hr</div></div>
</div>
<div class="card">
<div class="card-t">Rack Occupancy</div>
<div class="card-v" id="vOcc">0%</div>
<div class="pbar"><div class="pfill" id="occBar" style="width:0%;background:#059669"></div></div>
<div class="card-s"><span id="vFilled">0</span>/<span id="vTotal">0</span> slots | <span id="vPallets">0</span> pallets</div>
</div>
<div class="card">
<div class="card-t">Occupancy by ABC Zone</div>
<div style="margin-top:4px">
<div class="pr"><label style="width:40px;color:#ef4444;font-weight:700">A:</label><div class="pbar" style="flex:1"><div class="pfill" id="abcA" style="width:0%;background:#ef4444"></div></div><span class="u" id="abcAv">0%</span></div>
<div class="pr"><label style="width:40px;color:#eab308;font-weight:700">B:</label><div class="pbar" style="flex:1"><div class="pfill" id="abcB" style="width:0%;background:#eab308"></div></div><span class="u" id="abcBv">0%</span></div>
<div class="pr"><label style="width:40px;color:#3b82f6;font-weight:700">C:</label><div class="pbar" style="flex:1"><div class="pfill" id="abcC" style="width:0%;background:#3b82f6"></div></div><span class="u" id="abcCv">0%</span></div>
</div>
</div>
<div class="card">
<div class="card-t">Forklift Utilization</div>
<div class="card-v" id="vFUtil">0%</div>
<div class="pbar"><div class="pfill" id="fBar" style="width:0%;background:#3b82f6"></div></div>
<div class="fk-list" id="fkList"></div>
</div>
<div class="card">
<div class="card-t">Throughput / Hour</div>
<div class="chart-box"><canvas id="chartTp"></canvas></div>
</div>
<div class="card">
<div class="card-t">Avg Travel Distance</div>
<div class="card-v" id="vDist">0 m</div>
<div class="card-s" id="vDistTrips">0 trips measured</div>
</div>
</div>
<!-- Analysis tab -->
<div class="tab-content" id="tab-analysis">
<div class="card">
<div class="card-t">Bottleneck Analysis</div>
<div class="bottleneck" id="bnInfo">Waiting for data...</div>
</div>
<div class="card">
<div class="card-t">Capacity Summary</div>
<div class="bottleneck" id="capInfo"></div>
</div>
<div class="card">
<div class="card-t">Efficiency Metrics</div>
<div class="bottleneck" id="effInfo"></div>
</div>
<div class="card">
<div class="card-t">Recommendations</div>
<div class="bottleneck" id="recInfo">Run simulation to see recommendations</div>
</div>
</div>
<!-- Log tab -->
<div class="tab-content" id="tab-log">
<div class="log" id="logBox"></div>
</div>
</div>
<script>
// ===================== ENGINE =====================
const PX = 16; // px per meter
let cv, simOn=false, simPause=false, simT=0, raf=null, lastT=0;
let zones=[], slots=[], forks=[], heat=[];
let nIn=0, nOut=0, hIn=[], hOut=[], cIn=0, cOut=0, lastH=0;
let pIn=0, pOut=0;
let showH=false, showTr=false;
let tripDists=[], totalTrips=0;
// ---- TYPES ----
const ZT={
'dock-in':{c:'#0d9488'},
'dock-out':{c:'#0891b2'},
'stage-in':{c:'#7c3aed'},
'stage-out':{c:'#7c3aed'},
'rack':{c:'#c2410c'},
'office':{c:'#475569'},
'charge':{c:'#ca8a04'},
};
// ---- BUILD ----
function build(){
cv=document.getElementById('cv');
cv.innerHTML='';
zones=[];slots=[];
const W=+gv('pWhW'),D=+gv('pWhD');
const rR=+gv('pRRows'),rB=+gv('pRBays'),rL=+gv('pRLevels');
const bayW=+gv('pBayW'),rD=+gv('pRDepth'),aW=+gv('pAisleW');
const abcStr=gv('pABC').split(',').map(Number);
const initFill=(+gv('pInitFill'))/100;
cv.style.width=W*PX+'px';cv.style.height=D*PX+'px';
cv.style.backgroundSize=PX+'px '+PX+'px';
cv.style.backgroundImage='linear-gradient(to right,rgba(30,41,59,0.25) 1px,transparent 1px),linear-gradient(to bottom,rgba(30,41,59,0.25) 1px,transparent 1px)';
document.getElementById('whInfo').textContent=W+'m x '+D+'m';
// Rulers every 5m
for(let x=5;x<W;x+=5){let r=document.createElement('div');r.className='ruler';r.style.left=x*PX-6+'px';r.style.top='-14px';r.textContent=x+'m';cv.appendChild(r)}
for(let y=5;y<D;y+=5){let r=document.createElement('div');r.className='ruler';r.style.left='-22px';r.style.top=y*PX-6+'px';r.textContent=y+'m';cv.appendChild(r)}
// Docks
addZone('dock-in','Receiving\nDock',0,2,3,D-4,'dock-in');
addZone('dock-out','Shipping\nDock',W-3,2,3,D-4,'dock-out');
// Staging
addZone('stage-in','Inbound\nStaging',4,1.5,3,D-3,'stage-in');
addZone('stage-out','Outbound\nStaging',W-7,1.5,3,D-3,'stage-out');
// Office & charging
addZone('office','Office',0,0,6,1.5,'office');
addZone('charge','Charging\nStation',W-4,0,4,1.5,'charge');
// Racks with ABC
const rackAreaX=9, rackAreaEndX=W-9;
const rackTotalW=rB*bayW;
const rackStartX=rackAreaX+((rackAreaEndX-rackAreaX)-rackTotalW)/2;
const rackStartY=2.5;
// Determine ABC zones (A=rows closest to docks)
const abcA=Math.max(1,Math.round(rR*abcStr[0]/100));
const abcB=Math.max(1,Math.round(rR*abcStr[1]/100));
for(let r=0;r<rR;r++){
const ry=rackStartY+r*(rD*2+aW); // double-deep back-to-back
if(ry+rD*2>D-1)break;
const abc=r<abcA?'A':(r<abcA+abcB?'B':'C');
const zi=addZone('rack',`Row ${String.fromCharCode(65+r)}\n(${abc}-Zone)`,rackStartX,ry,rackTotalW,rD*2,'rack');
zones[zi].abc=abc;
zones[zi].levels=rL;
zones[zi].bays=rB;
zones[zi].rackRow=r;
// Create slots
for(let l=0;l<rL;l++){
for(let b=0;b<rB;b++){
const fillProb=abc==='A'?initFill*1.3:abc==='B'?initFill:initFill*0.7;
slots.push({zi,level:l,bay:b,abc,filled:Math.random()<Math.min(fillProb,1),
x:rackStartX+b*bayW+bayW/2, y:ry+rD});
}
}
// Aisle label
if(r<rR-1){
const ay=ry+rD*2+aW/2;
const al=document.createElement('div');
al.className='aisle-label';
al.style.left=rackStartX*PX+'px';
al.style.top=(ay-0.4)*PX+'px';
al.style.width=rackTotalW*PX+'px';
al.textContent=`AISLE ${r+1}`;
cv.appendChild(al);
}
}
// Heatmap grid
heat=[];
for(let y=0;y<D;y++){heat[y]=[];for(let x=0;x<W;x++)heat[y][x]=0}
}
function addZone(type,label,x,y,w,h,id){
const i=zones.length;
const z={id:id||'z'+i,type,label,x,y,w,h,abc:null,levels:0,bays:0};
zones.push(z);
const el=document.createElement('div');
el.className='zone'+(z.abc?' abc-'+z.abc.toLowerCase():'');
el.id='z-'+z.id;
el.style.left=x*PX+'px';el.style.top=y*PX+'px';
el.style.width=w*PX+'px';el.style.height=h*PX+'px';
el.style.background=ZT[type]?.c||'#333';
let inner=`<span class="z-label">${label.replace(/\n/g,'<br>')}</span>`;
inner+=`<span class="z-dim">${w.toFixed(1)}x${h.toFixed(1)}m</span>`;
if(type==='rack'){
inner+=`<div class="fill-overlay" id="fo-${z.id}" style="height:0%;background:rgba(0,0,0,0.4)"></div>`;
inner+=`<span class="fill-pct" id="fp-${z.id}"></span>`;
}
el.innerHTML=inner;
cv.appendChild(el);
// Add ABC tag after render
if(z.abc){
setTimeout(()=>{
const tag=document.createElement('span');
tag.className='abc-tag';
tag.style.background=z.abc==='A'?'#ef4444':z.abc==='B'?'#eab308':'#3b82f6';
tag.style.color='#fff';
tag.textContent=z.abc;
el.appendChild(tag);
el.classList.add('abc-'+z.abc.toLowerCase());
},0);
}
return i;
}
// ---- FORKLIFTS ----
function makeForks(){
document.querySelectorAll('.fk').forEach(e=>e.remove());
forks=[];
const n=+gv('pForks'), D=+gv('pWhD');
const cols=['#60a5fa','#34d399','#f472b6','#a78bfa','#fbbf24','#fb923c','#38bdf8','#4ade80','#e879f9','#f87171','#818cf8','#22d3ee'];
const chargeZ=zones.find(z=>z.id==='charge');
for(let i=0;i<n;i++){
const f={id:i,color:cols[i%cols.length],x:chargeZ?chargeZ.x+1+i:5,y:chargeZ?chargeZ.y+0.7:1,
state:'idle',task:null,carry:false,tgtX:0,tgtY:0,busyUntil:0,
busyT:0,totalT:0,tripDist:0,curDist:0,waypoints:[],wpIdx:0};
forks.push(f);
const el=document.createElement('div');
el.className='fk';el.id='fk-'+i;
el.style.left=(f.x*PX-7)+'px';el.style.top=(f.y*PX-7)+'px';
el.style.background=f.color;
el.innerHTML=`<span class="fk-id">F${i+1}</span>`;
cv.appendChild(el);
}
}
// ---- PATHFINDING (waypoint-based along aisles) ----
function planPath(fx,fy,tx,ty){
// Simple waypoint: go to nearest aisle Y, travel horizontally, go to target aisle Y, then target
const racks=zones.filter(z=>z.type==='rack');
if(racks.length===0)return[{x:tx,y:ty}];
const rD=+gv('pRDepth'), aW=+gv('pAisleW');
// Find aisle Y positions (between racks)
const aisleYs=[];
const sortedRacks=[...racks].sort((a,b)=>a.y-b.y);
// Top aisle (above first rack)
aisleYs.push(sortedRacks[0].y-0.5);
for(let i=0;i<sortedRacks.length-1;i++){
aisleYs.push(sortedRacks[i].y+sortedRacks[i].h+aW/2);
}
// Bottom aisle
aisleYs.push(sortedRacks[sortedRacks.length-1].y+sortedRacks[sortedRacks.length-1].h+0.5);
// Cross aisle X positions (left and right of rack area)
const rackMinX=Math.min(...racks.map(r=>r.x));
const rackMaxX=Math.max(...racks.map(r=>r.x+r.w));
const crossL=rackMinX-1;
const crossR=rackMaxX+1;
// Find nearest aisle to source and target
const nearestAisle=(y)=>aisleYs.reduce((best,ay)=>Math.abs(ay-y)<Math.abs(best-y)?ay:best,aisleYs[0]);
const srcAisle=nearestAisle(fy);
const tgtAisle=nearestAisle(ty);
// Choose cross aisle side
const crossX=fx<(rackMinX+rackMaxX)/2?crossL:crossR;
const wps=[];
if(Math.abs(srcAisle-tgtAisle)<0.5){
// Same aisle
wps.push({x:tx,y:srcAisle});
wps.push({x:tx,y:ty});
} else {
// Go to cross aisle, change aisle, go to target
wps.push({x:fx,y:srcAisle}); // enter source aisle
wps.push({x:crossX,y:srcAisle}); // go to cross aisle
wps.push({x:crossX,y:tgtAisle}); // travel to target aisle
wps.push({x:tx,y:tgtAisle}); // enter target aisle
wps.push({x:tx,y:ty}); // arrive
}
return wps;
}
function assignTask(f){
const racks=zones.filter(z=>z.type==='rack');
if(!racks.length)return;
const doIn=pIn>=1||(pOut<1&&Math.random()<0.55);
const stageIn=zones.find(z=>z.id==='stage-in');
const stageOut=zones.find(z=>z.id==='stage-out');
if(doIn&&pIn>=1){
pIn--;
// Prefer A-zone racks for inbound (high turnover)
const abcSlots=slots.filter(s=>!s.filled);
const aSlots=abcSlots.filter(s=>s.abc==='A');
const target=aSlots.length>0&&Math.random()<0.6?aSlots[Math.floor(Math.random()*aSlots.length)]:abcSlots.length>0?abcSlots[Math.floor(Math.random()*abcSlots.length)]:null;
if(!target){f.state='idle';return}
const pickX=stageIn.x+stageIn.w/2;
const pickY=stageIn.y+Math.random()*stageIn.h;
f.task='inbound';
f.waypoints=planPath(f.x,f.y,pickX,pickY);
f.waypoints.push({x:pickX,y:pickY,action:'pick'});
const dropWps=planPath(pickX,pickY,target.x,target.y);
dropWps[dropWps.length-1].action='drop';
dropWps[dropWps.length-1].slotIdx=slots.indexOf(target);
dropWps[dropWps.length-1].level=target.level;
f.waypoints.push(...dropWps);
f.wpIdx=0;f.state='moving';f.carry=false;f.curDist=0;
setWpTarget(f);
} else if(pOut>=1){
pOut--;
const filledSlots=slots.filter(s=>s.filled);
const aFilled=filledSlots.filter(s=>s.abc==='A');
const target=aFilled.length>0&&Math.random()<0.6?aFilled[Math.floor(Math.random()*aFilled.length)]:filledSlots.length>0?filledSlots[Math.floor(Math.random()*filledSlots.length)]:null;
if(!target){f.state='idle';return}
f.task='outbound';
f.waypoints=planPath(f.x,f.y,target.x,target.y);
f.waypoints[f.waypoints.length-1].action='pick';
f.waypoints[f.waypoints.length-1].slotIdx=slots.indexOf(target);
f.waypoints[f.waypoints.length-1].level=target.level;
const dropX=stageOut.x+stageOut.w/2;
const dropY=stageOut.y+Math.random()*stageOut.h;
const dropWps=planPath(target.x,target.y,dropX,dropY);
dropWps[dropWps.length-1].action='drop';
f.waypoints.push(...dropWps);
f.wpIdx=0;f.state='moving';f.carry=false;f.curDist=0;
setWpTarget(f);
} else {
f.state='idle';
}
}
function setWpTarget(f){
if(f.wpIdx>=f.waypoints.length){
// Trip done
if(f.curDist>0){tripDists.push(f.curDist);totalTrips++}
f.state='idle';f.task=null;return;
}
const wp=f.waypoints[f.wpIdx];
f.tgtX=wp.x;f.tgtY=wp.y;
}
// ---- SIMULATION TICK ----
function tick(dtR){
const spd=+gv('pSpeed');
const dt=dtR*spd;
const dur=(+gv('pDur'))*3600;
const fSpd=+gv('pForkSpd');
const pickT=+gv('pPick');
const putT=+gv('pPut');
const lvlP=+gv('pLvlP');
const inR=(+gv('pInRate'))/3600;
const outR=(+gv('pOutRate'))/3600;
const peakHrs=gv('pPeak').split(',').map(Number);
const peakM=+gv('pPeakM');
simT+=dt;
if(simT>=dur){endSim();return}
// Hour tracking
const curH=Math.floor(simT/3600);
if(curH>lastH){hIn.push(cIn);hOut.push(cOut);cIn=0;cOut=0;lastH=curH}
// Demand with peak pattern
const h=simT/3600%24;
const isPeak=peakHrs.some(ph=>Math.abs(h-ph)<0.5);
const mult=isPeak?peakM:1;
pIn+=inR*mult*dt;
pOut+=outR*mult*dt;
// Forklifts
const W=+gv('pWhW'),D=+gv('pWhD');
forks.forEach(f=>{
f.totalT+=dt;
if(f.state==='idle'){assignTask(f);return}
if(f.state==='busy'){
if(simT>=f.busyUntil){
f.busyT+=(f.busyUntil-(simT-dt)>0?f.busyUntil-(simT-dt):0);
const wp=f.waypoints[f.wpIdx];
if(wp.action==='pick'){
f.carry=true;
if(f.task==='outbound'&&wp.slotIdx!=null)slots[wp.slotIdx].filled=false;
}else if(wp.action==='drop'){
f.carry=false;
if(f.task==='inbound'&&wp.slotIdx!=null)slots[wp.slotIdx].filled=true;
if(f.task==='inbound'){nIn++;cIn++;addLog('i',`F${f.id+1} stored pallet`)}
else{nOut++;cOut++;addLog('o',`F${f.id+1} shipped pallet`)}
}
f.wpIdx++;
setWpTarget(f);
}else{f.busyT+=dt}
return;
}
// Moving
const dx=f.tgtX-f.x,dy=f.tgtY-f.y;
const dist=Math.sqrt(dx*dx+dy*dy);
const step=fSpd*dt;
if(dist<=step+0.05){
f.curDist+=dist;
f.x=f.tgtX;f.y=f.tgtY;
f.busyT+=dt;
const wp=f.waypoints[f.wpIdx];
if(wp.action){
const lvl=wp.level||0;
const wt=(wp.action==='pick'?pickT:putT)+lvl*lvlP;
f.state='busy';f.busyUntil=simT+wt;
}else{
f.wpIdx++;setWpTarget(f);
}
}else{
const moved=step;
f.x+=dx/dist*moved;f.y+=dy/dist*moved;
f.curDist+=moved;f.busyT+=dt;
}
// Heatmap
const gx=Math.floor(f.x),gy=Math.floor(f.y);
if(heat[gy]&&heat[gy][gx]!=null)heat[gy][gx]+=dt;
});
render();
}
// ---- RENDER ----
function render(){
// Clock
const h=Math.floor(simT/3600),m=Math.floor(simT%3600/60),s=Math.floor(simT%60);
document.getElementById('clock').textContent=String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');
// Forklifts
forks.forEach(f=>{
const el=document.getElementById('fk-'+f.id);
if(!el)return;
el.style.left=(f.x*PX-7)+'px';el.style.top=(f.y*PX-7)+'px';
el.className='fk'+(f.carry?' carry':'');
el.style.background=f.carry?'#fbbf24':f.color;
if(showTr&&f.state==='moving'){
const t=document.createElement('div');t.className='trail';
t.style.left=f.x*PX+'px';t.style.top=f.y*PX+'px';t.style.background=f.color;
cv.appendChild(t);setTimeout(()=>t.remove(),4000);
}
});
// Stats
const hrs=Math.max(simT/3600,0.01);
document.getElementById('vIn').textContent=nIn;
document.getElementById('vOut').textContent=nOut;
document.getElementById('vInR').textContent=(nIn/hrs).toFixed(1)+'/hr';
document.getElementById('vOutR').textContent=(nOut/hrs).toFixed(1)+'/hr';
// Slots
const filled=slots.filter(s=>s.filled).length;
const total=slots.length;
const occ=total>0?(filled/total*100):0;
document.getElementById('vOcc').textContent=occ.toFixed(1)+'%';
document.getElementById('occBar').style.width=occ+'%';
document.getElementById('occBar').style.background=occ>90?'#ef4444':occ>70?'#eab308':'#059669';
document.getElementById('vFilled').textContent=filled;
document.getElementById('vTotal').textContent=total;
document.getElementById('vPallets').textContent=filled;
// ABC occupancy
['A','B','C'].forEach(abc=>{
const as=slots.filter(s=>s.abc===abc);
const af=as.filter(s=>s.filled).length;
const ap=as.length>0?(af/as.length*100):0;
document.getElementById('abc'+abc).style.width=ap+'%';
document.getElementById('abc'+abc+'v').textContent=ap.toFixed(0)+'%';
});
// Rack fill visualization
zones.filter(z=>z.type==='rack').forEach(z=>{
const zi=zones.indexOf(z);
const rs=slots.filter(s=>s.zi===zi);
const rf=rs.filter(s=>s.filled).length;
const rp=rs.length>0?(rf/rs.length*100):0;
const fo=document.getElementById('fo-'+z.id);
const fp=document.getElementById('fp-'+z.id);
if(fo){fo.style.height=rp+'%';fo.style.background=rp>90?'rgba(239,68,68,0.4)':rp>70?'rgba(234,179,8,0.3)':'rgba(0,0,0,0.35)'}
if(fp)fp.textContent=rp.toFixed(0)+'%';
});
// Fork utilization
let totU=0;let fkH='';
forks.forEach(f=>{
const u=f.totalT>0?(f.busyT/f.totalT*100):0;totU+=u;
const st=f.state==='idle'?'IDLE':f.carry?'CARRY':'MOVE';
fkH+=`<div class="fk-row"><div class="fk-dot" style="background:${f.color}"></div><span>F${f.id+1} ${st}</span><div class="fk-bar"><div class="fk-bar-fill" style="width:${u}%;background:${f.color}"></div></div><span style="width:30px;text-align:right">${u.toFixed(0)}%</span></div>`;
});
const avgU=forks.length>0?totU/forks.length:0;
document.getElementById('vFUtil').textContent=avgU.toFixed(0)+'%';
document.getElementById('fBar').style.width=avgU+'%';
document.getElementById('fBar').style.background=avgU>85?'#ef4444':avgU>60?'#eab308':'#3b82f6';
document.getElementById('fkList').innerHTML=fkH;
// Distance
const avgDist=tripDists.length>0?(tripDists.reduce((a,b)=>a+b,0)/tripDists.length):0;
document.getElementById('vDist').textContent=avgDist.toFixed(1)+' m';
document.getElementById('vDistTrips').textContent=totalTrips+' trips measured';
// Chart (every 30 frames)
if(Math.random()<0.1)drawChart();
// Bottleneck (every ~2s)
if(Math.random()<0.05)updateAnalysis();
// Heatmap
if(showH&&Math.random()<0.05)renderHeat();
}
function drawChart(){
const c=document.getElementById('chartTp');
const ctx=c.getContext('2d');
const w=c.width=c.offsetWidth*2,h=c.height=c.offsetHeight*2;
ctx.clearRect(0,0,w,h);
const allIn=[...hIn,cIn],allOut=[...hOut,cOut];
const mx=Math.max(1,...allIn,...allOut);
const bw=allIn.length>0?Math.min(w/allIn.length-3,24):24;
allIn.forEach((v,i)=>{
const x=i*(bw+3)+4;
const bh=v/mx*(h-20);
ctx.fillStyle='rgba(52,211,153,0.6)';ctx.fillRect(x,h-bh-12,bw/2-1,bh);
const oh=(allOut[i]||0)/mx*(h-20);
ctx.fillStyle='rgba(249,115,22,0.6)';ctx.fillRect(x+bw/2,h-oh-12,bw/2-1,oh);
ctx.fillStyle='#334155';ctx.font='12px sans-serif';ctx.fillText(i+'h',x,h-2);
});
ctx.fillStyle='rgba(52,211,153,0.8)';ctx.fillRect(w-90,2,8,8);ctx.fillStyle='#64748b';ctx.font='12px sans-serif';ctx.fillText('In',w-78,10);
ctx.fillStyle='rgba(249,115,22,0.8)';ctx.fillRect(w-50,2,8,8);ctx.fillText('Out',w-38,10);
}
function updateAnalysis(){
const inR=+gv('pInRate'),outR=+gv('pOutRate');
const n=forks.length,pickT=+gv('pPick'),putT=+gv('pPut');
const avgDist=tripDists.length>0?tripDists.reduce((a,b)=>a+b,0)/tripDists.length:30;
const fSpd=+gv('pForkSpd');
const avgCycle=pickT+putT+avgDist/fSpd*2;
const maxCap=Math.floor(n*3600/avgCycle);
const demand=inR+outR;
const filled=slots.filter(s=>s.filled).length;
const occ=slots.length>0?filled/slots.length:0;
// Bottleneck
let bn='';
if(demand>maxCap*0.9)bn+=`<div class="bn-crit">CRITICAL: Forklift capacity ${maxCap}/hr < demand ${demand}/hr</div>`;
else if(demand>maxCap*0.7)bn+=`<div class="bn-warn">WARNING: Approaching capacity limit (${(demand/maxCap*100).toFixed(0)}%)</div>`;
else bn+=`<div class="bn-ok">Forklift capacity OK (${(demand/maxCap*100).toFixed(0)}% of max)</div>`;
if(occ>0.92)bn+=`<div class="bn-crit">CRITICAL: Rack occupancy ${(occ*100).toFixed(0)}%</div>`;
else if(occ>0.75)bn+=`<div class="bn-warn">WARNING: Rack occupancy ${(occ*100).toFixed(0)}%</div>`;
else bn+=`<div class="bn-ok">Rack space OK (${(occ*100).toFixed(0)}%)</div>`;
if(pIn>n*3)bn+=`<div class="bn-warn">Inbound queue: ${Math.floor(pIn)} pallets waiting</div>`;
if(pOut>n*3)bn+=`<div class="bn-warn">Outbound queue: ${Math.floor(pOut)} pallets waiting</div>`;
document.getElementById('bnInfo').innerHTML=bn||'<div class="bn-ok">All systems nominal</div>';
// Capacity
document.getElementById('capInfo').innerHTML=`
<div>Max forklift throughput: <b>${maxCap}/hr</b></div>
<div>Current demand: <b>${demand}/hr</b></div>
<div>Avg cycle time: <b>${avgCycle.toFixed(0)}s</b></div>
<div>Avg travel distance: <b>${avgDist.toFixed(1)}m</b></div>
<div>Total rack slots: <b>${slots.length}</b></div>
<div>Remaining capacity: <b>${slots.length-filled} slots</b></div>
`;
// Efficiency
const avgU=forks.reduce((s,f)=>s+(f.totalT>0?f.busyT/f.totalT:0),0)/Math.max(forks.length,1)*100;
const throughput=(nIn+nOut)/Math.max(simT/3600,0.01);
document.getElementById('effInfo').innerHTML=`
<div>Avg forklift utilization: <b>${avgU.toFixed(1)}%</b></div>
<div>Total throughput: <b>${throughput.toFixed(1)}/hr</b></div>
<div>Inbound processed: <b>${nIn}</b></div>
<div>Outbound processed: <b>${nOut}</b></div>
<div>Space utilization: <b>${(occ*100).toFixed(1)}%</b></div>
`;
// Recommendations
let rec='';
if(avgU>85)rec+=`<div class="bn-warn">Consider adding ${Math.ceil((demand-maxCap*0.7)/(3600/avgCycle))} more forklifts</div>`;
if(occ>0.8){
const hrsLeft=(slots.length-filled)/Math.max((nIn-nOut)/Math.max(simT/3600,0.01),0.01);
rec+=`<div class="bn-warn">At current rate, full in ~${hrsLeft.toFixed(1)} hrs</div>`;
}
if(avgDist>40)rec+=`<div class="bn-warn">High avg distance (${avgDist.toFixed(0)}m). Review ABC allocation</div>`;
const aSlots=slots.filter(s=>s.abc==='A');
const aOcc=aSlots.length>0?aSlots.filter(s=>s.filled).length/aSlots.length:0;
if(aOcc>0.9)rec+=`<div class="bn-crit">A-Zone nearly full (${(aOcc*100).toFixed(0)}%). Rebalance needed</div>`;
if(!rec)rec='<div class="bn-ok">No immediate actions required</div>';
document.getElementById('recInfo').innerHTML=rec;
}
function renderHeat(){
document.querySelectorAll('.hcell').forEach(e=>e.remove());
const W=+gv('pWhW'),D=+gv('pWhD');
const mx=Math.max(1,...heat.flat());
for(let y=0;y<D;y+=2){
for(let x=0;x<W;x+=2){
const v=(heat[y]?.[x]||0)+(heat[y]?.[x+1]||0)+(heat[y+1]?.[x]||0)+(heat[y+1]?.[x+1]||0);
if(v<0.5)continue;
const i=Math.min(v/mx,1);
const el=document.createElement('div');el.className='hcell';
el.style.left=x*PX+'px';el.style.top=y*PX+'px';el.style.width=PX*2+'px';el.style.height=PX*2+'px';
const r=Math.floor(255*i),g=Math.floor(80*(1-i));
el.style.background=`rgba(${r},${g},0,${i*0.35})`;
cv.appendChild(el);
}
}
}
// ---- LOG ----
function addLog(type,msg){
const box=document.getElementById('logBox');
const h=Math.floor(simT/3600),m=Math.floor(simT%3600/60);
const ts=String(h).padStart(2,'0')+':'+String(m).padStart(2,'0');
const cls=type==='i'?'l-i':type==='o'?'l-o':type==='w'?'l-w':type==='e'?'l-e':'';
box.innerHTML=`<div><span class="l-t">[${ts}]</span> <span class="${cls}">${msg}</span></div>`+box.innerHTML;
while(box.children.length>200)box.removeChild(box.lastChild);
}
// ---- CONTROLS ----
function startSim(){
if(simOn&&simPause){simPause=false;document.getElementById('bPause').textContent='Pause';document.getElementById('bStatus').textContent='Running';document.getElementById('bStatus').className='badge badge-status';lastT=performance.now();loop();return}
resetSim();build();makeForks();
simOn=true;simPause=false;
document.getElementById('bStart').disabled=true;
document.getElementById('bPause').disabled=false;
document.getElementById('bStatus').textContent='Running';document.getElementById('bStatus').className='badge badge-status';
document.getElementById('bSpeed').textContent=gv('pSpeed')+'x';
addLog('','Simulation started');
lastT=performance.now();loop();
}
function loop(){
if(!simOn||simPause)return;
const now=performance.now();
const dtR=Math.min((now-lastT)/1000,0.1);
lastT=now;
tick(dtR);
raf=requestAnimationFrame(loop);
}
function pauseSim(){
if(!simOn)return;
simPause=!simPause;
document.getElementById('bPause').textContent=simPause?'Resume':'Pause';
document.getElementById('bStatus').textContent=simPause?'Paused':'Running';
document.getElementById('bStatus').className='badge badge-status'+(simPause?' paused':'');
if(!simPause){lastT=performance.now();loop()}
}
function endSim(){
simOn=false;if(raf)cancelAnimationFrame(raf);
document.getElementById('bStart').disabled=false;
document.getElementById('bPause').disabled=true;
document.getElementById('bStatus').textContent='Done';document.getElementById('bStatus').className='badge badge-status done';
hIn.push(cIn);hOut.push(cOut);
drawChart();updateAnalysis();
addLog('','Simulation completed');
}
function resetSim(){
simOn=false;simPause=false;if(raf)cancelAnimationFrame(raf);
simT=0;lastT=0;nIn=0;nOut=0;hIn=[];hOut=[];cIn=0;cOut=0;lastH=0;pIn=0;pOut=0;tripDists=[];totalTrips=0;
document.getElementById('bStart').disabled=false;
document.getElementById('bPause').disabled=true;
document.getElementById('bStatus').textContent='Ready';document.getElementById('bStatus').className='badge badge-status';
document.getElementById('clock').textContent='00:00:00';
document.getElementById('vIn').textContent='0';document.getElementById('vOut').textContent='0';
document.getElementById('vInR').textContent='0/hr';document.getElementById('vOutR').textContent='0/hr';
document.getElementById('logBox').innerHTML='';
build();makeForks();render();
}
function toggleHeat(){showH=!showH;document.getElementById('bHeat').classList.toggle('btn-act',showH);if(!showH)document.querySelectorAll('.hcell').forEach(e=>e.remove())}
function toggleTrail(){showTr=!showTr;document.getElementById('bTrail').classList.toggle('btn-act',showTr);if(!showTr)document.querySelectorAll('.trail').forEach(e=>e.remove())}
// ---- SCENARIOS ----
function scenario(name){
if(name==='base'){sv('pWhW',50);sv('pWhD',30);sv('pRRows',6);sv('pRBays',12);sv('pRLevels',4);sv('pForks',5);sv('pInRate',40);sv('pOutRate',35);sv('pInitFill',45)}
else if(name==='peak'){sv('pInRate',70);sv('pOutRate',60);sv('pPeakM',2.2)}
else if(name==='addfork'){sv('pForks',7)}
else if(name==='dense'){sv('pRRows',8);sv('pRBays',15);sv('pAisleW',2.8)}
resetSim();
}
// ---- TABS ----
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
document.querySelector(`.tab-content#tab-${name}`).classList.add('active');
event.target.classList.add('active');
}
// ---- UTIL ----
function gv(id){return document.getElementById(id).value}
function sv(id,v){document.getElementById(id).value=v}
// Speed change
document.getElementById('pSpeed').addEventListener('change',function(){document.getElementById('bSpeed').textContent=this.value+'x'});
// Init
build();makeForks();
</script>
</body>
</html>
```앞으로의 계획이 있다면 들려주세요.
(실제 창고 도면, 인력 및 창고내 운반장비, 랙 세부내용을 입력하고 인력수, 지게차, 물류운영에 병목구간을 확인 혹은 물류자원 예측을 가능합니다.)