You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1000 lines
40 KiB

// 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 = `<span class="log-time">[${timeStr}]</span> <span class="log-msg">${textData}</span>`;
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"]
},
'ABNORM': {name: "이상 행동", classes: ["fallen", "person", "car", "motor", "bus", "truck"], defaults: ["fallen"]},
'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 = '<div style="color:#777; text-align:center; padding:10px;">Waiting for card data...</div>';
} 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"}},
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;
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 = `<span style="display: inline-block; width: 12px; height: 12px; background-color: ${color}; margin-right: 8px; vertical-align: middle;"></span>`;
row.innerHTML = `<span class="label">${colorBox}${key}</span><span class="count">${count}</span>`;
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 = `
<div class="summary-row">
<span class="label">거리 위험도(%)</span>
<span class="count">${distRisk}</span>
</div>
<div class="summary-row">
<span class="label">이동 위험도(%)</span>
<span class="count">${motionRisk}</span>
</div>
<div class="summary-row">
<span class="label" style="color: #ff5555; font-weight: bold;">종합 위험도(%)</span>
<span class="count" style="color: #ff5555; font-weight: bold;">${totalRisk}</span>
</div>
`;
}
}
function updateCardPanel(items) {
if (!summaryListEl) return;
summaryListEl.innerHTML = '';
if (!items || items.length === 0) {
summaryListEl.innerHTML = '<div style="color:#777; text-align:center; padding:10px;">No card data</div>';
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 = `
<img src="${personImgSrc}" class="card-person-img">
<div class="card-right">
<img src="${faceImgSrc}" class="card-face-img">
<div class="card-appear-info">${item.appear || ''}</div>
</div>
`;
} else if (currentModelCode === 'ABNORM') {
const fallenImgSrc = item.fallen ? `data:image/jpeg;base64,${item.fallen}` : '';
card.innerHTML = `
<img src="${fallenImgSrc}" class="card-person-img" style="width:120px; height:auto; max-height:150px;">
<div class="card-right">
<div class="card-appear-info">Event ID: ${item.tid || '-'}</div>
</div>
`;
} 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 = `
<img src="${carImgSrc}" class="card-car-img">
<div class="card-right">
<img src="${lpImgSrc}" class="card-lp-img">
<div class="card-ocr-info">${item.ocr || ''}</div>
</div>
`;
}
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 `<div class="preview-item"><img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')"><button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button></div>`;
}).join('');
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
}
const fileInputId = `poi-file-${item.name}`;
tr.innerHTML = `<td>${index + 1}</td><td><span style="font-weight:bold; margin-right:10px;">${item.name}</span><button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button></td><td>${previewHtml}</td><td><input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)"><label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333;">찾아보기</label></td>`;
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