// app.js document.addEventListener('DOMContentLoaded', () => { // ================================================= // 0. 로그아웃 로직 // ================================================= const logoutBtn = document.getElementById('logout-button'); if (logoutBtn) { logoutBtn.addEventListener('click', () => { if (confirm('정말로 로그아웃 하시겠습니까?')) { window.location.href = '/'; } }); } // ================================================= // 1. 탭 전환 로직 (상단 메인 탭) // ================================================= const tabVideo = document.getElementById('tab-video'); const tabModels = document.getElementById('tab-models'); const contentVideo = document.getElementById('content-video'); const contentModels = document.getElementById('content-models'); if (tabVideo && tabModels) { tabVideo.addEventListener('click', () => { tabVideo.classList.add('active'); tabModels.classList.remove('active'); contentVideo.classList.add('active'); contentModels.classList.remove('active'); updateBboxContainerPosition(); // Mission View 탭 활성화 시 Zoom In 토글 보이기 updateZoomInToggleVisibility(true); }); tabModels.addEventListener('click', () => { tabModels.classList.add('active'); tabVideo.classList.remove('active'); contentModels.classList.add('active'); contentVideo.classList.remove('active'); loadModelList(); loadPoiList(); // Settings 탭 활성화 시 Zoom In 토글 숨기기 updateZoomInToggleVisibility(false); }); } // ================================================= // [신규] 오른쪽 사이드바 (Summary / Log) 탭 로직 // ================================================= const btnSummary = document.getElementById('btn-summary'); const btnLog = document.getElementById('btn-log'); const summaryListEl = document.getElementById('summary-list'); const logListEl = document.getElementById('log-list'); const logIntervalContainer = document.getElementById('log-interval-container'); const logIntervalToggle = document.getElementById('log-interval-toggle'); let logBuffer = []; let logUpdateInterval = null; if (btnSummary && btnLog) { btnSummary.addEventListener('click', () => { btnSummary.classList.add('active'); btnLog.classList.remove('active'); if(summaryListEl) summaryListEl.classList.remove('hidden'); if(logListEl) logListEl.classList.add('hidden'); if(logIntervalContainer) logIntervalContainer.classList.add('hidden'); }); btnLog.addEventListener('click', () => { btnLog.classList.add('active'); btnSummary.classList.remove('active'); if(summaryListEl) summaryListEl.classList.add('hidden'); if(logListEl) logListEl.classList.remove('hidden'); if(logIntervalContainer) logIntervalContainer.classList.remove('hidden'); }); } function flushLogBuffer() { if (logBuffer.length > 0) { const fragment = document.createDocumentFragment(); logBuffer.forEach(textData => { const row = createLogEntry(textData); fragment.appendChild(row); }); logListEl.appendChild(fragment); logBuffer = []; while (logListEl.children.length > 50) { logListEl.removeChild(logListEl.firstChild); } logListEl.scrollTop = logListEl.scrollHeight; } } function startLogInterval() { if (logUpdateInterval) clearInterval(logUpdateInterval); logUpdateInterval = setInterval(flushLogBuffer, 1000); } function stopLogInterval() { clearInterval(logUpdateInterval); logUpdateInterval = null; } if (logIntervalToggle) { logIntervalToggle.addEventListener('change', (e) => { if (e.target.checked) { startLogInterval(); } else { stopLogInterval(); flushLogBuffer(); } }); if (logIntervalToggle.checked) { startLogInterval(); } } function createLogEntry(textData) { const now = new Date(); const timeStr = now.toTimeString().split(' ')[0]; const row = document.createElement('div'); row.className = 'log-entry'; row.innerHTML = `[${timeStr}] ${textData}`; return row; } function updateLogPanel(textData) { if (!logListEl) return; if (logIntervalToggle && logIntervalToggle.checked) { logBuffer.push(textData); if (logBuffer.length > 50) { logBuffer.shift(); // 버퍼가 50개를 넘으면 가장 오래된 로그 제거 } } else { const row = createLogEntry(textData); logListEl.appendChild(row); while (logListEl.children.length > 50) { logListEl.removeChild(logListEl.firstChild); } logListEl.scrollTop = logListEl.scrollHeight; } } // ================================================= // [신규] Mission UI 로직 // ================================================= const MODEL_DEFINITIONS = { 'OBJDET': { name: "객체 탐지", classes: ["person", "car", "van", "truck", "bus", "motor"] }, 'FIRE': { name: "화재 감지", classes: ["flame", "smoke"] }, 'CROWD': { name: "군중 위험", classes: ["person", "crowd"] }, 'FACEATTR': { name: "얼굴 인식", classes: ["face"] }, 'ABNORM': { name: "이상 행동", classes: ["fall", "fight"] }, 'LPR': { name: "차량 인식", classes: ["plate"] }, 'VIPTRACK': { name: "관심 인물", classes: ["person"] } }; let currentModelCode = 'OBJDET'; let activeClassFilters = new Set(); const zoomInContainer = document.getElementById('zoom-in-container'); const zoomToggle = document.getElementById('zoom-toggle'); const showLabelToggle = document.getElementById('show-label-toggle'); if (zoomToggle) { zoomToggle.addEventListener('change', (e) => { if(e.target.checked) { console.log("Zoom In Activated"); } else { console.log("Zoom In Deactivated"); } }); } if (showLabelToggle) { showLabelToggle.addEventListener('change', () => { if (lastFrameMeta) { showDetections(lastFrameMeta); } }); } const navItems = document.querySelectorAll('.nav-item'); navItems.forEach(item => { item.addEventListener('click', () => { navItems.forEach(nav => nav.classList.remove('active')); item.classList.add('active'); const modelCode = item.dataset.model; if(modelCode) { currentModelCode = modelCode; changeModel(modelCode); updateFilterBar(modelCode); } }); }); function changeModel(modelCode) { fetch('/set-model', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({model: modelCode}) }).then(r => console.log(`Model changed to ${modelCode}`)) .catch(e => console.error(e)); } function updateZoomInToggleVisibility(show) { if (!zoomInContainer) return; if (show) { zoomInContainer.classList.remove('hidden'); } else { zoomInContainer.classList.add('hidden'); } } const filterBar = document.getElementById('filter-bar'); function updateFilterBar(modelCode) { if(!filterBar) return; filterBar.innerHTML = ''; const def = MODEL_DEFINITIONS[modelCode]; if(!def) return; const allLabel = document.createElement('label'); allLabel.className = 'filter-check'; const allInput = document.createElement('input'); allInput.type = 'checkbox'; allInput.checked = true; allLabel.appendChild(allInput); allLabel.append(' all'); const sep = document.createElement('span'); sep.className = 'v-line'; sep.innerText = '|'; filterBar.appendChild(allLabel); filterBar.appendChild(sep); const classInputs = []; def.classes.forEach(cls => { const label = document.createElement('label'); label.className = 'filter-check'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = true; input.dataset.cls = cls; label.appendChild(input); label.append(` ${cls}`); filterBar.appendChild(label); classInputs.push(input); input.addEventListener('change', () => { const allChecked = classInputs.every(i => i.checked); allInput.checked = allChecked; updateActiveFilters(classInputs); }); }); allInput.addEventListener('change', () => { const isChecked = allInput.checked; classInputs.forEach(inp => inp.checked = isChecked); updateActiveFilters(classInputs); }); updateActiveFilters(classInputs); } function updateActiveFilters(classInputs) { activeClassFilters.clear(); classInputs.forEach(inp => { if(inp.checked) activeClassFilters.add(inp.dataset.cls); }); } updateFilterBar('OBJDET'); updateZoomInToggleVisibility(true); // ================================================= // 2. 비디오 & 웹소켓 & Summary 패널 // ================================================= const LABEL_MAP = { 1: { tagName: "객체 탐지", classes: { 0: "person", 1: "car", 2: "van", 3: "truck", 4: "bus", 5: "motor" } }, 2: { tagName: "화재 인식", classes: { 0: "flame", 1: "smoke" } }, 3: { tagName: "얼굴 인식", classes: { 0: "face" } }, 4: { tagName: "차량번호", classes: { 0: "plate" } } }; function getBoxColor(clsName) { const colorMap = { 'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500', 'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC' }; return colorMap[clsName] || '#FFFFFF'; } const uri = "ws://10.10.11.246:8765"; const canvasEl = document.getElementById("frame"); const bboxContainerEl = document.getElementById("bbox-container"); const fpsDisplayEl = document.getElementById("fps-display"); const resolutionEl = document.getElementById("resolution-display"); const connMsgEl = document.getElementById("connection-msg"); let ctx = null; if (canvasEl) ctx = canvasEl.getContext('2d'); let frameCount = 0; let lastFpsCheckTime = performance.now(); let lastFrameMeta = null; let viewConfig = { r: 1, dx: 0, dy: 0 }; function connect() { const ws = new WebSocket(uri); ws.binaryType = "arraybuffer"; ws.onopen = () => { if(connMsgEl) connMsgEl.textContent = "영상 수신 중"; }; ws.onclose = () => { if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)"; setTimeout(connect, 2000); }; ws.onmessage = (event) => { const data = event.data; if (typeof data === "string") { updateLogPanel(data); try { const meta = JSON.parse(data); if (meta.type === "frame") { lastFrameMeta = meta; showDetections(meta); } } catch(e){ console.error(e); } } else if (data instanceof ArrayBuffer && lastFrameMeta) { frameCount++; const now = performance.now(); if (now - lastFpsCheckTime >= 1000) { if(fpsDisplayEl) fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`; frameCount = 0; lastFpsCheckTime = now; } const blob = new Blob([data], {type: "image/jpeg"}); createImageBitmap(blob).then(bmp => { if (resolutionEl) { resolutionEl.textContent = `${bmp.width}x${bmp.height}`; } canvasEl.width = canvasEl.clientWidth; canvasEl.height = canvasEl.clientHeight; const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height); const dw = bmp.width * r; const dh = bmp.height * r; const dx = (canvasEl.width - dw) / 2; const dy = (canvasEl.height - dh) / 2; viewConfig = { r, dx, dy }; ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); ctx.drawImage(bmp, dx, dy, dw, dh); bmp.close(); updateBboxContainerPosition(); }); } }; } function showDetections(meta) { bboxContainerEl.innerHTML = ""; const items = meta.items || []; const currentCounts = {}; const shouldShowLabel = showLabelToggle ? showLabelToggle.checked : false; items.forEach((it) => { const tagId = it.tag !== undefined ? it.tag : -1; const clsId = it.cls !== undefined ? it.cls : -1; let displayClassName = `Cls ${clsId}`; if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) { displayClassName = LABEL_MAP[tagId].classes[clsId]; } if (!activeClassFilters.has(displayClassName)) { return; } if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0; currentCounts[displayClassName]++; const x1 = it.x1 || 0; const y1 = it.y1 || 0; const x2 = it.x2 || 0; const y2 = it.y2 || 0; const boxColor = getBoxColor(displayClassName); const { r, dx, dy } = viewConfig; const screenX = dx + (x1 * r); const screenY = dy + (y1 * r); const screenW = (x2 - x1) * r; const screenH = (y2 - y1) * r; const boxDiv = document.createElement('div'); boxDiv.className = 'bbox'; boxDiv.style.left = `${screenX}px`; boxDiv.style.top = `${screenY}px`; boxDiv.style.width = `${screenW}px`; boxDiv.style.height = `${screenH}px`; boxDiv.style.borderColor = boxColor; if (shouldShowLabel) { const label = document.createElement('div'); label.style.position = 'absolute'; label.style.top = '-20px'; label.style.left = '-2px'; label.style.backgroundColor = boxColor; label.style.color = '#000'; label.style.fontSize = '11px'; label.style.fontWeight = 'bold'; label.style.padding = '1px 4px'; label.textContent = displayClassName; boxDiv.appendChild(label); } bboxContainerEl.appendChild(boxDiv); }); updateSummaryPanel(currentCounts); } function updateSummaryPanel(counts) { if (!summaryListEl) return; summaryListEl.innerHTML = ''; const keys = Object.keys(counts); if (keys.length === 0) { summaryListEl.innerHTML = '