// 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", "motor", "bus", "truck"], defaults: ["person", "car", "motor", "bus", "truck"] }, 'FIRE': {name: "화재 감지", classes: ["flame", "smoke"], defaults: ["flame", "smoke"]}, 'CROWD': {name: "군중 위험", classes: ["person", "car", "motor", "bus", "truck"], defaults: ["person"]}, // 'FACEATTR': { // name: "얼굴 인식", // classes: ["face", "person", "car", "motor", "bus", "truck"], // defaults: ["face", "person"] // }, //디버깅용 2025.12.15 // 0: "shirt (blouse)" // 1: "t-shirt" // 2: "sweater" // 3: "cardigan" // 4: "jacket" // 5: "vest" // 6: "pants" // 7: "shorts" // 8: "skirt" // 9: "coat" // 10: "dress" // 11: "bag, wallet" 'FACEATTR': { name: "얼굴 인식", classes: ["shirt (blouse)", "t-shirt", "sweater", "cardigan", "jacket", "vest", "pants", "shorts", "skirt", "coat", "dress", "bag, wallet"], defaults: ["shirt (blouse)", "t-shirt", "sweater", "cardigan", "jacket", "vest", "pants", "shorts", "skirt", "coat", "dress", "bag, wallet"] }, 'ABNORM': {name: "이상 행동", classes: ["fallen", "person", "car", "motor", "bus", "truck"], defaults: ["fallen", "person"]}, 'LPR': { name: "차량 인식", classes: ["plate", "person", "car", "motor", "bus", "truck"], defaults: ["plate", "car", "motor", "bus", "truck"] }, 'VIPTRACK': {name: "관심 인물", classes: ["person", "face"], defaults: ["person", "face"]} }; 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'); const showTidToggle = document.getElementById('show-tid-toggle'); const riskSummaryContainer = document.getElementById('risk-summary-container'); if (zoomToggle) { zoomToggle.addEventListener('change', (e) => { const state = e.target.checked ? "IN" : "OUT"; fetch('/zoom-control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({zoom: state}) }) .then(response => response.json()) .then(data => console.log(`Zoom control ${state}:`, data)) .catch(error => console.error('Error setting zoom:', error)); }); } if (showLabelToggle) { showLabelToggle.addEventListener('change', () => { if (lastFrameMeta) { showDetections(lastFrameMeta); } }); } if (showTidToggle) { showTidToggle.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); if (modelCode === 'CROWD') { riskSummaryContainer.classList.remove('hidden'); } else { riskSummaryContainer.classList.add('hidden'); } if (modelCode === 'FACEATTR' || modelCode === 'ABNORM' || modelCode === 'LPR') { summaryListEl.className = 'summary-content card-list'; summaryListEl.innerHTML = '
Waiting for card data...
'; } else { summaryListEl.className = 'summary-content'; summaryListEl.innerHTML = ''; detectedClasses.clear(); } } }); }); 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 defaults = def.defaults || def.classes; const isAllDefault = (def.classes.length === defaults.length) && def.classes.every(c => defaults.includes(c)); const allLabel = document.createElement('label'); allLabel.className = 'filter-check'; const allInput = document.createElement('input'); allInput.type = 'checkbox'; allInput.checked = isAllDefault; 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 = defaults.includes(cls); 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: "-", 2: "car", 3: "motor", 4: "bus", 5: "truck"}}, 2: {tagName: "화재 인식", classes: {0: "flame", 1: "smoke"}}, // 3: {tagName: "얼굴 인식", classes: {0: "face"}}, //디버깅용 2025./12.15 // 0: "shirt (blouse)" // 1: "t-shirt" // 2: "sweater" // 3: "cardigan" // 4: "jacket" // 5: "vest" // 6: "pants" // 7: "shorts" // 8: "skirt" // 9: "coat" // 10: "dress" // 11: "bag, wallet" 3: {tagName: "얼굴 인식", classes: {0: "shirt (blouse)", 1: "t-shirt", 2: "sweater", 3: "cardigan", 4: "jacket", 5: "vest", 6: "pants", 7: "shorts", 8: "skirt", 9: "coat", 10: "dress", 11: "bag, wallet"}}, 4: {tagName: "차량번호", classes: {0: "plate"}}, 5: {tagName: "이상 행동", classes: {0: "fallen"}} }; function getBoxColor(clsName) { const colorMap = { 'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500', 'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC', 'fall': '#FF0000', 'fight': '#FF0000' }; 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}; let detectedClasses = new Set(); // 한 번이라도 탐지된 클래스 목록 let mosaicState = {}; // TID별 모자이크 상태 저장 (true: 모자이크, false: 해제) // 모자이크용 오프스크린 캔버스 const mosaicCanvas = document.createElement('canvas'); const mosaicCtx = mosaicCanvas.getContext('2d'); function applyMosaic(x, y, width, height) { if (!ctx || width <= 0 || height <= 0) return; const blockSize = 20; // 모자이크 블록 크기 const smallW = Math.floor(width / blockSize); const smallH = Math.floor(height / blockSize); if (smallW <= 0 || smallH <= 0) return; mosaicCanvas.width = smallW; mosaicCanvas.height = smallH; // 1. 원본 캔버스의 영역을 축소해서 임시 캔버스에 그림 mosaicCtx.drawImage(canvasEl, x, y, width, height, 0, 0, smallW, smallH); // 2. 임시 캔버스의 내용을 다시 원본 캔버스에 확대해서 그림 (픽셀화) ctx.save(); ctx.imageSmoothingEnabled = false; // 픽셀화 효과를 위해 스무딩 끄기 ctx.drawImage(mosaicCanvas, 0, 0, smallW, smallH, x, y, width, height); ctx.restore(); } 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") { console.log("-----" + data); updateLogPanel(data); try { const meta = JSON.parse(data); if (meta.type === "frame") { lastFrameMeta = meta; showDetections(meta); } else if (meta.type === "card" && (currentModelCode === 'FACEATTR' || currentModelCode === 'ABNORM' || currentModelCode === 'LPR')) { lastFrameMeta = meta; showDetections(meta); updateCardPanel(meta.items); } } 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); if (currentModelCode === 'VIPTRACK' && lastFrameMeta && lastFrameMeta.items) { lastFrameMeta.items.forEach(item => { const tagId = item.tag !== undefined ? item.tag : -1; const clsId = item.cls !== undefined ? item.cls : -1; let displayClassName = ''; if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) { displayClassName = LABEL_MAP[tagId].classes[clsId]; } if (displayClassName === 'face') { const { r, dx, dy } = viewConfig; const x1 = item.x1 || 0, y1 = item.y1 || 0, x2 = item.x2 || 0, y2 = item.y2 || 0; const screenX = dx + (x1 * r), screenY = dy + (y1 * r); const screenW = (x2 - x1) * r, screenH = (y2 - y1) * r; const tid = item.tid; const isMosaicOn = (tid !== undefined && mosaicState[tid] !== undefined) ? mosaicState[tid] : true; if (isMosaicOn && screenW > 0 && screenH > 0) { applyMosaic(screenX, screenY, screenW, screenH); } } }); } bmp.close(); updateBboxContainerPosition(); }); } }; } if (canvasEl) { canvasEl.addEventListener('click', (e) => { if (currentModelCode !== 'VIPTRACK' || !lastFrameMeta || !lastFrameMeta.items) return; const rect = canvasEl.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; const { r, dx, dy } = viewConfig; lastFrameMeta.items.forEach(item => { const tagId = item.tag !== undefined ? item.tag : -1; const clsId = item.cls !== undefined ? item.cls : -1; let displayClassName = ''; if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) { displayClassName = LABEL_MAP[tagId].classes[clsId]; } if (displayClassName === 'face') { const x1 = item.x1 || 0; const y1 = item.y1 || 0; const x2 = item.x2 || 0; const y2 = item.y2 || 0; const screenX = dx + (x1 * r); const screenY = dy + (y1 * r); const screenW = (x2 - x1) * r; const screenH = (y2 - y1) * r; if (clickX >= screenX && clickX <= screenX + screenW && clickY >= screenY && clickY <= screenY + screenH) { const tid = item.tid; if (tid !== undefined && tid !== 65535) { if (mosaicState[tid] === undefined) { mosaicState[tid] = false; } else { mosaicState[tid] = !mosaicState[tid]; } } } } }); }); canvasEl.addEventListener('mousemove', (e) => { if (currentModelCode !== 'VIPTRACK' || !lastFrameMeta || !lastFrameMeta.items) { canvasEl.style.cursor = 'default'; return; } const rect = canvasEl.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const { r, dx, dy } = viewConfig; let onFace = false; for (const item of lastFrameMeta.items) { const tagId = item.tag !== undefined ? item.tag : -1; const clsId = item.cls !== undefined ? item.cls : -1; let displayClassName = ''; if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) { displayClassName = LABEL_MAP[tagId].classes[clsId]; } if (displayClassName === 'face' && item.tid !== 65535) { const x1 = item.x1 || 0, y1 = item.y1 || 0, x2 = item.x2 || 0, y2 = item.y2 || 0; const screenX = dx + (x1 * r), screenY = dy + (y1 * r); const screenW = (x2 - x1) * r, screenH = (y2 - y1) * r; if (mouseX >= screenX && mouseX <= screenX + screenW && mouseY >= screenY && mouseY <= screenY + screenH) { onFace = true; break; } } } canvasEl.style.cursor = onFace ? 'pointer' : 'default'; }); } function showDetections(meta) { bboxContainerEl.innerHTML = ""; if (currentModelCode === 'CROWD' && meta.trajectory) { const personColor = getBoxColor('person'); ctx.save(); ctx.lineWidth = 2; // ctx.strokeStyle = personColor; ctx.strokeStyle = '#FF0000'; Object.values(meta.trajectory).forEach(points => { if (points.length < 2) return; ctx.beginPath(); const startX = viewConfig.dx + (points[0][0] * viewConfig.r); const startY = viewConfig.dy + (points[0][1] * viewConfig.r); ctx.moveTo(startX, startY); for (let i = 1; i < points.length; i++) { const px = viewConfig.dx + (points[i][0] * viewConfig.r); const py = viewConfig.dy + (points[i][1] * viewConfig.r); ctx.lineTo(px, py); } ctx.stroke(); }); ctx.restore(); } const items = meta.items || []; const currentCounts = {}; const shouldShowLabel = showLabelToggle ? showLabelToggle.checked : false; const shouldShowTid = showTidToggle ? showTidToggle.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; let boxColor = getBoxColor(displayClassName); if (currentModelCode === 'ABNORM') { boxColor = '#FF0000'; } 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; let labelText = ''; if (shouldShowLabel) { labelText += displayClassName; } if (shouldShowTid && it.tid !== undefined) { labelText += ` ${it.tid}`; } if (labelText) { 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 = labelText.trim(); boxDiv.appendChild(label); } bboxContainerEl.appendChild(boxDiv); }); if (currentModelCode !== 'FACEATTR' && currentModelCode !== 'ABNORM' && currentModelCode !== 'LPR') { updateSummaryPanel(currentCounts, meta.risk); } } function updateSummaryPanel(counts, riskData) { if (!summaryListEl) return; Object.keys(counts).forEach(key => detectedClasses.add(key)); summaryListEl.innerHTML = ''; detectedClasses.forEach(key => { const count = counts[key] || 0; const row = document.createElement('div'); row.className = 'summary-row'; const color = getBoxColor(key); const colorBox = ``; row.innerHTML = `${colorBox}${key}${count}`; summaryListEl.appendChild(row); }); if (currentModelCode === 'CROWD' && riskData) { const distRisk = (riskData.dist_risk !== undefined) ? (riskData.dist_risk * 100).toFixed(2) : '-'; const motionRisk = (riskData.motion_risk !== undefined) ? (riskData.motion_risk * 100).toFixed(2) : '-'; const totalRisk = (riskData.risk_total !== undefined) ? (riskData.risk_total * 100).toFixed(2) : '-'; riskSummaryContainer.innerHTML = `
거리 위험도(%) ${distRisk}
이동 위험도(%) ${motionRisk}
종합 위험도(%) ${totalRisk}
`; } } function updateCardPanel(items) { if (!summaryListEl) return; summaryListEl.innerHTML = ''; if (!items || items.length === 0) { summaryListEl.innerHTML = '
No card data
'; return; } items.forEach(item => { const card = document.createElement('div'); card.className = 'card'; card.dataset.tid = item.tid; if (currentModelCode === 'FACEATTR') { const personImgSrc = item.person ? `data:image/jpeg;base64,${item.person}` : ''; const faceImgSrc = item.face ? `data:image/jpeg;base64,${item.face}` : ''; card.innerHTML = `
${item.appear || ''}
`; } else if (currentModelCode === 'ABNORM') { const fallenImgSrc = item.fallen ? `data:image/jpeg;base64,${item.fallen}` : ''; card.innerHTML = `
쓰러짐 발생
`; } else if (currentModelCode === 'LPR') { const carImgSrc = item.car ? `data:image/jpeg;base64,${item.car}` : ''; const lpImgSrc = item.lp ? `data:image/jpeg;base64,${item.lp}` : ''; card.innerHTML = `
${item.ocr || ''}
`; } summaryListEl.appendChild(card); }); } function updateBboxContainerPosition() { if (!canvasEl || !bboxContainerEl) return; bboxContainerEl.style.top = canvasEl.offsetTop + 'px'; bboxContainerEl.style.left = canvasEl.offsetLeft + 'px'; bboxContainerEl.style.width = canvasEl.offsetWidth + 'px'; bboxContainerEl.style.height = canvasEl.offsetHeight + 'px'; } if (canvasEl) connect(); window.addEventListener('resize', updateBboxContainerPosition); const resizer = document.getElementById('drag-handle'); const rightPanel = document.querySelector('.mission-right'); if (resizer && rightPanel) { resizer.addEventListener('mousedown', (e) => { e.preventDefault(); const startX = e.clientX; const startRightWidth = rightPanel.getBoundingClientRect().width; const onMouseMove = (e) => { const newWidth = startRightWidth - (e.clientX - startX); rightPanel.style.width = `${newWidth}px`; updateBboxContainerPosition(); }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); } function loadModelList() { const tableBody = document.querySelector('.model-table tbody'); if (!tableBody) return; fetch('/list-models').then(r => r.json()).then(models => { tableBody.querySelectorAll('tr[data-role]').forEach(row => { const role = row.dataset.role; const d = models[role]; const f = row.querySelector('.model-filename'); const v = row.querySelector('.model-version'); const btn = row.querySelector('.btn-delete'); if (d) { f.textContent = d.file; v.textContent = d.version; btn.classList.remove('hidden'); } else { f.textContent = '-'; v.textContent = '-'; btn.classList.add('hidden'); } }); }); } const modelTableBody = document.querySelector('.model-table tbody'); if (modelTableBody) { modelTableBody.addEventListener('click', (e) => { if (e.target.classList.contains('btn-delete')) { const row = e.target.closest('tr'); const filename = row.querySelector('.model-filename').textContent; if (filename && filename !== '-') deleteModelFile(filename); } }); } function deleteModelFile(filename) { if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return; fetch('/delete-model', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({filename: filename}) }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); } else { alert(`삭제 실패: ${data.message}`); } }); } const globalUploadBtn = document.getElementById('global-upload-button'); const globalFileInput = document.getElementById('global-file-input'); const fileNameDisplay = document.getElementById('file-name-display'); const refreshButton = document.querySelector('.refresh-button'); if (globalFileInput) { globalFileInput.addEventListener('change', () => { fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음'; }); } if (globalUploadBtn) { globalUploadBtn.addEventListener('click', () => { if (!globalFileInput.files.length) return alert('파일을 선택해주세요.'); const formData = new FormData(); formData.append('modelFile', globalFileInput.files[0]); fetch('/upload-model', {method: 'POST', body: formData}) .then(res => res.json()) .then(data => { if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value = ''; fileNameDisplay.textContent = '선택된 파일 없음'; loadModelList(); } else { alert(`실패: ${data.message}`); } }); }); } if (refreshButton) refreshButton.addEventListener('click', loadModelList); const poiTableBody = document.querySelector('#poi-table tbody'); const poiEmptyMsg = document.getElementById('poi-empty-msg'); const btnPoiRegister = document.getElementById('btn-poi-register'); const btnPoiRefresh = document.getElementById('btn-poi-refresh'); const poiModal = document.getElementById('poi-modal'); const poiNameInput = document.getElementById('poi-name-input'); const btnModalConfirm = document.getElementById('btn-modal-confirm'); const btnModalCancel = document.getElementById('btn-modal-cancel'); const imageViewerModal = document.getElementById('image-viewer-modal'); const fullImage = document.getElementById('full-image'); const closeViewerBtn = document.querySelector('.close-viewer'); function loadPoiList() { if (!poiTableBody) return; fetch('/poi/list') .then(res => res.json()) .then(list => { poiTableBody.innerHTML = ''; if (!list || list.length === 0) { poiEmptyMsg.classList.remove('hidden'); poiTableBody.parentElement.classList.add('hidden'); } else { poiEmptyMsg.classList.add('hidden'); poiTableBody.parentElement.classList.remove('hidden'); list.forEach((item, index) => { const tr = document.createElement('tr'); let previewHtml = '-'; if (item.images && item.images.length > 0) { const imagesHtml = item.images.map(imgName => { const imgSrc = `/poi-images/${item.name}/${imgName}`; return `
`; }).join(''); previewHtml = `
${imagesHtml}
`; } const fileInputId = `poi-file-${item.name}`; tr.innerHTML = `${index + 1}${item.name}${previewHtml}`; poiTableBody.appendChild(tr); }); } }) .catch(err => console.error('POI Load Error:', err)); } window.openImageViewer = function (src) { if (fullImage && imageViewerModal) { fullImage.src = src; imageViewerModal.classList.remove('hidden'); } }; if (closeViewerBtn) { closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); }); } window.addEventListener('click', (e) => { if (e.target === imageViewerModal) { imageViewerModal.classList.add('hidden'); } }); window.deletePoi = function (name) { if (!confirm(`관심인물 '${name}' 삭제?`)) return; fetch('/poi/delete', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name}) }).then(res => res.json()).then(data => { if (data.status === 'success') loadPoiList(); }); }; window.deletePoiImage = function (name, imageName) { if (!confirm(`이미지 삭제?`)) return; fetch('/poi/delete-image', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, imageName}) }).then(res => res.json()).then(data => { if (data.status === 'success') loadPoiList(); }); }; window.uploadPoiImage = function (name, input) { if (!input.files || input.files.length === 0) return; const formData = new FormData(); formData.append('poiName', name); for (let i = 0; i < input.files.length; i++) { formData.append('poiFile', input.files[i]); } fetch('/poi/upload-image', {method: 'POST', body: formData}).then(res => res.json()).then(data => { if (data.status === 'success') { alert('완료'); loadPoiList(); } else { alert('실패'); } }); input.value = ''; }; if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList); if (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); } if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); } if (btnModalConfirm) { btnModalConfirm.addEventListener('click', () => { const name = poiNameInput.value.trim(); if (!name) return alert('이름 입력'); fetch('/poi/register', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name}) }).then(res => res.json()).then(data => { if (data.status === 'success') { poiModal.classList.add('hidden'); loadPoiList(); } else { alert('실패'); } }); }); } loadModelList(); }); //2025-12-09 14:41