Change Dashboard features.

main
dongjin kim 6 months ago
parent e521498c07
commit 8600ed534b

@ -1,610 +0,0 @@
// 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');
});
tabModels.addEventListener('click', () => {
tabModels.classList.add('active');
tabVideo.classList.remove('active');
contentModels.classList.add('active');
contentVideo.classList.remove('active');
loadModelList();
loadPoiList();
});
}
// =================================================
// 2. AI 모델 관리 로직
// =================================================
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);
// =================================================
// 3. 관심 인물(POI) 관리 로직
// =================================================
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');
// POI 목록 로드
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}')" title="클릭하여 확대">
<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; font-size:1.1rem; 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; border:1px solid #ccc;">찾아보기</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}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) 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();
else alert('삭제 실패');
});
};
// [전역 함수] 특정 이미지만 삭제
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();
else alert('이미지 삭제 실패');
});
};
// 다중 파일 업로드 처리
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++) {
const file = input.files[i];
const ext = file.name.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
input.value = '';
return;
}
formData.append('poiFile', file);
}
fetch('/poi/upload-image', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('이미지 등록 완료');
loadPoiList();
} else {
alert('업로드 실패: ' + (data.message || '오류'));
}
})
.catch(() => 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('이름을 입력하세요.');
const regex = /^[a-zA-Z0-9]+$/;
if (!regex.test(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(data.message || '등록 실패');
}
});
});
}
// =================================================
// 4. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
// =================================================
// [설정] Tag/Class 매핑 테이블
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: "License plate"
}
}
};
// [설정] 박스 색상 생성 함수
function getBoxColor(tag, cls) {
// Tag 2: 화재 관련 (긴급한 색상)
if (tag === 2) {
if (cls === 0) return '#FF0000'; // Flame: 빨강
if (cls === 1) return '#808080'; // Smoke: 회색
}
// Tag 1: 객체 탐지 (다양한 색상)
if (tag === 1) {
const colors = [
'#00FF00', // Person: 라임 그린
'#00FFFF', // Car: 시안
'#FFA500', // Van: 오렌지
'#FF69B4', // Truck: 핫핑크
'#9370DB', // Bus: 미디엄 퍼플
'#FFD700' // Motor: 골드
];
return colors[cls % colors.length] || '#FFFFFF';
}
// Tag 3: 얼굴
if (tag === 3) return '#1E90FF'; // 도저 블루
// Tag 4: 번호판
if (tag === 4) return '#32CD32'; // 라임 그린
// 기타 기본값
return '#FF0000';
}
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const statusEl = document.getElementById("status");
const detListEl = document.getElementById("det-list");
const bboxContainerEl = document.getElementById("bbox-container");
const modelContainerEl = document.getElementById("current-model-container");
const modelDisplayEl = document.getElementById("current-model-display");
const fpsDisplayEl = document.getElementById("fps-display");
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 = () => {
statusEl.textContent = "연결됨";
statusEl.className = "status";
};
ws.onclose = () => {
statusEl.textContent = "연결 종료. 재접속...";
statusEl.className = "status err";
setTimeout(connect, 2000);
};
ws.onmessage = (event) => {
const data = event.data;
// 1. 텍스트 데이터(JSON) 처리
if (typeof data === "string") {
console.log("-----" + data); // 필요시 주석 해제
try {
const meta = JSON.parse(data);
if (meta.type === "frame") {
lastFrameMeta = meta;
document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
showDetections(meta);
}
} catch(e){ console.error(e); }
// 2. 바이너리 데이터(영상 프레임) 처리
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
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 => {
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();
});
}
};
}
/**
* [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수
* - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
* - Class별 색상 구분 적용
*/
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const ch = meta.ch !== undefined ? meta.ch : '-';
const seq = meta.seq !== undefined ? meta.seq : '-';
const ts = meta.ts_us !== undefined ? meta.ts_us : '-';
let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`];
const items = meta.items || [];
if (items.length === 0) {
outputLines.push("No detections");
} else {
items.forEach((it, i) => {
const x1 = it.x1 || 0;
const y1 = it.y1 || 0;
const x2 = it.x2 || 0;
const y2 = it.y2 || 0;
// 데이터에서 tag와 cls 추출
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
const tId = it.tid !== undefined ? it.tid : -1;
// 1. 이름 매핑 Lookup
let displayTagName = `Tag ${tagId}`;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId]) {
displayTagName = LABEL_MAP[tagId].tagName;
if (LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
}
// 2. 색상 결정
const boxColor = getBoxColor(tagId, clsId);
// 로그창 출력 (원본 데이터 유지)
outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
// 좌표 변환
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;
// 박스 DOM 생성
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; // 테두리 색상 변경
// 라벨 생성
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px'; // 박스 바로 위
label.style.left = '-2px'; // 테두리 두께 고려 정렬
label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
label.style.color = 'white'; // 글자는 흰색
label.style.fontSize = '12px';
label.style.fontWeight = 'bold';
label.style.padding = '2px 6px';
label.style.borderRadius = '3px';
label.style.whiteSpace = 'nowrap';
label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
// [요청 포맷 적용] Class 명 : Tag id
label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
boxDiv.appendChild(label);
bboxContainerEl.appendChild(boxDiv);
});
}
detListEl.textContent = outputLines.join("\n");
}
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 (modelContainerEl) {
modelContainerEl.addEventListener('change', (e) => {
if(e.target.name === 'current-model') {
updateModelDisplay();
fetch('/set-model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: e.target.value})
});
}
});
}
function updateModelDisplay() {
if(!modelContainerEl || !modelDisplayEl) return;
const checked = modelContainerEl.querySelector('input:checked');
if(checked) {
const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
modelDisplayEl.textContent = label ? label.textContent : checked.value;
}
}
const detPanelToggle = document.getElementById('det-panel-toggle');
const detWrap = document.getElementById('det-wrap');
if (detPanelToggle && detWrap) {
detPanelToggle.addEventListener('change', (e) => {
if (e.target.checked) {
// 체크됨 -> 패널 보이기 (기존 flex 속성 복구)
detWrap.classList.remove('collapsed-panel');
} else {
// 체크 해제 -> 패널 숨기기
detWrap.classList.add('collapsed-panel');
}
// 패널 크기가 변하므로 캔버스 내 BBox 위치 재계산 필요
// 약간의 지연 후 실행 (Flexbox 애니메이션 등이 없으므로 즉시 해도 되지만 안전하게)
setTimeout(() => {
if(typeof updateBboxContainerPosition === 'function') {
updateBboxContainerPosition();
}
}, 50);
});
}
// 초기 실행
updateModelDisplay();
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
loadPoiList();
loadModelList();
});

@ -1,189 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - AI Drone System</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<nav class="tabs">
<div>
<button id="tab-video" class="tab-button active">Video</button>
<button id="tab-models" class="tab-button">Settings</button>
</div>
<button id="logout-button" class="logout-button">Logout</button>
</nav>
</header>
<main>
<div id="content-video" class="tab-content active">
<div class="video-container">
<div class="video-player">
<div id="video-wrap">
<div id="info">
<span id="status" class="status">연결 시도 중...</span>
<span id="frame-info"></span>
<div class="panel-toggle-container">
<label class="switch">
<input type="checkbox" id="det-panel-toggle">
<span class="slider round"></span>
</label>
<span class="toggle-text">Detections</span>
</div>
</div>
<span id="current-model-display" class="video-overlay top-right hidden"></span>
<span id="fps-display" class="video-overlay bottom-right"></span>
<canvas id="frame"></canvas>
<div id="bbox-container"></div>
</div>
<div id="det-wrap" class="collapsed-panel">
<div id="det-title">Detections</div>
<div id="det-list"></div>
</div>
<div id="current-model-container" class="segmented-control-container">
<input type="radio" id="model-objdet" name="current-model" value="OBJDET" checked>
<label for="model-objdet" class="segmented-control-button">객체 탐지/분류</label>
<input type="radio" id="model-fire" name="current-model" value="FIRE">
<label for="model-fire" class="segmented-control-button">화재 감지</label>
<input type="radio" id="model-crowd" name="current-model" value="CROWD">
<label for="model-crowd" class="segmented-control-button">군중 위험 인식</label>
<input type="radio" id="model-deid" name="current-model" value="FACEATTR">
<label for="model-deid" class="segmented-control-button">얼굴/ 인상착의 인식</label>
<input type="radio" id="model-abnorm" name="current-model" value="ABNORM">
<label for="model-abnorm" class="segmented-control-button">이상행동 감지</label>
<input type="radio" id="model-lpr" name="current-model" value="LPR">
<label for="model-lpr" class="segmented-control-button">차량 번호판 인식</label>
<input type="radio" id="model-viptrack" name="current-model" value="VIPTRACK">
<label for="model-viptrack" class="segmented-control-button">관심인물추적</label>
</div>
</div>
</div>
</div>
<div id="content-models" class="tab-content">
<div class="toolbar">
<label class="section-title">AI 모델파일 관리</label>
<input type="file" id="global-file-input" class="hidden-file-input" accept=".aiwbin">
<label for="global-file-input" class="btn-browse">찾아보기</label>
<span id="file-name-display">선택된 파일 없음</span>
<button id="global-upload-button" class="btn-action">⬆️ 업로드</button>
<button class="refresh-button">🔄 새로고침</button>
</div>
<table class="model-table">
<thead>
<tr>
<th>번호</th>
<th>AI Model 역할</th>
<th>파일명</th>
<th>현재 버전</th>
<th>삭제</th>
</tr>
</thead>
<tbody>
<tr data-role="OBJDET">
<td>1</td>
<td>객체 탐지/ 분류</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FIRE">
<td>2</td>
<td>화재(불꽃, 연기) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="ABNORM">
<td>3</td>
<td>이상행동(쓰러짐, 폭행) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FACEATTR">
<td>4</td>
<td>얼굴/ 인상착의 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="LPR">
<td>5</td>
<td>차량 번호판/ 차종 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
</tbody>
</table>
<div class="divider"></div>
<div class="toolbar">
<label class="section-title">관심 인물파일 관리</label>
<div class="right-actions">
<button id="btn-poi-register" class="btn-white">등록</button>
<button id="btn-poi-refresh" class="refresh-button">🔄 새로 고침</button>
</div>
</div>
<table class="model-table" id="poi-table">
<thead>
<tr>
<th style="width: 10%;">번호</th>
<th style="width: 30%;">관심인물명</th>
<th style="width: 30%;">미리 보기</th>
<th style="width: 30%;">파일 등록</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div id="poi-empty-msg" class="empty-message hidden">
등록된 관심 인물이 없습니다
</div>
</div>
</main>
<div id="poi-modal" class="modal hidden">
<div class="modal-content">
<h3>관심인물 등록</h3>
<div class="modal-body">
<input type="text" id="poi-name-input" placeholder="관심인물명(영문숫자만 가능)" maxlength="20">
</div>
<div class="modal-actions">
<button id="btn-modal-confirm" class="btn-modal-confirm">확인</button>
<button id="btn-modal-cancel" class="btn-modal-cancel">취소</button>
</div>
</div>
</div>
<div id="image-viewer-modal" class="modal hidden" style="background-color: rgba(0,0,0,0.9);">
<span class="close-viewer">&times;</span>
<img class="modal-image" id="full-image">
</div>
<script src="app.js"></script>
</body>
</html>

@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Drone System - Login</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="login-page">
<h1 class="page-title">AI Mission Camera Console for Drones</h1>
<div class="login-container">
<h1>Login</h1>
<p class="login-subtext">Sign In to your account</p>
<form id="login-form">
<div class="input-group">
<span class="icon">👤</span>
<input type="text" id="id" name="id" placeholder="Username" required>
</div>
<div class="input-group">
<span class="icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="login-button">Login</button>
</form>
</div>
<div class="footer-bar">
<img src="science_dep.png" width="134" height="30"/>
<img src="kait_logo_on.png" width="128" height="40"/>
<img src="img_tta_logo.svg" width="140" height="40"/>
<img src="nexreal_logo-dark.svg" width="100" height="25"/>
<img src="cuuva_logo.png" width="66" height="30"/>
<img src="nextchip_logo.png" width="99" height="25"/>
<img src="datamaker_logo.svg" width="100" height="30"/>
</div>
<script>
// DOM(HTML)이 모두 로드되었을 때 실행
document.addEventListener('DOMContentLoaded', () => {
// ID가 'login-form'인 폼 요소를 가져옵니다.
const loginForm = document.getElementById('login-form');
if (loginForm) {
// 폼에서 'submit' 이벤트(로그인 버튼 클릭)가 발생했을 때 실행될 함수를 정의
loginForm.addEventListener('submit', (event) => {
// 1. 폼의 기본 제출 동작(페이지 새로고침 또는 이동)을 막습니다.
event.preventDefault();
// 2. ID와 비밀번호 입력 필드에서 사용자가 입력한 값을 가져옵니다.
const idInput = document.getElementById('id');
const passwordInput = document.getElementById('password');
const id = idInput.value;
const password = passwordInput.value;
// 3. ID와 비밀번호가 'admin'인지 확인합니다.
if (id === 'admin' && password === 'admin') {
// 4. 일치하면: 'dashboard.html' 페이지로 이동합니다.
console.log('Login successful. Redirecting to dashboard.html...');
window.location.href = 'dashboard.html';
} else {
// 5. 일치하지 않으면: 사용자에게 알림 창을 표시합니다.
alert('ID 또는 비밀번호가 올바지 않습니다.');
// (선택 사항) 틀린 비밀번호 필드를 비우고 다시 포커스합니다.
passwordInput.value = '';
passwordInput.focus();
}
});
}
});
</script>
</body>
</html>

@ -1,431 +0,0 @@
/* style.css */
/* 기본 및 공통 스타일 */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
}
/* 버튼 기본 스타일 reset 및 공통 */
button {
cursor: pointer;
border-radius: 4px;
border: 1px solid #555;
background-color: #3a3a3a;
color: #e0e0e0;
padding: 5px 10px;
}
/* 로그인 페이지 등 배경 스타일 */
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
/* 배경 이미지 설정 (전체 화면 꽉 채우기) */
background: url('drone_background.png') no-repeat center center fixed;
background-size: cover;
position: relative; /* page-title 절대 위치의 기준점 */
}
/* [수정됨] 로그인 화면 메인 타이틀 스타일 */
.page-title {
position: absolute;
top: 60px; /* 상단 여백 */
left: 60px; /* 좌측 여백 */
color: #ffffff; /* 흰색 텍스트 */
font-size: 3rem; /* 크기 2배 (약 64px ~ 70px) */
font-weight: 600;
margin: 0;
z-index: 10;
/* 배경과 구분을 위한 검은 그림자 */
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
}
.login-container {
background: rgba(255, 255, 255, 0.95); /* 배경이 비치지 않도록 불투명도 조정 */
padding: 2rem 3rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* 그림자 조금 더 진하게 */
width: 400px;
box-sizing: border-box;
z-index: 20;
}
.login-container h1 {
font-size: 2.2rem;
color: #333;
margin-bottom: 0.5rem;
font-weight: bold;
text-align: center; /* 로그인 박스 내부 타이틀 중앙 정렬 */
}
.login-subtext {
font-size: 1rem;
color: #666;
margin: 0 0 2.5rem 0;
text-align: center; /* 서브 텍스트 중앙 정렬 */
}
.input-group {
margin-bottom: 1rem;
position: relative;
text-align: left;
}
.input-group input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
background-color: #fff;
color: #333;
font-size: 1rem;
}
.input-group .icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
/* [수정됨] 로그인 버튼 스타일 (중앙 정렬 추가) */
.login-button {
width: auto;
min-width: 150px;
padding: 0.8rem 2.5rem;
background-color: #5a67d8;
color: white;
border: none;
font-size: 1rem;
font-weight: bold;
/* 중앙 정렬을 위한 설정 */
display: block;
margin: 1.5rem auto 0 auto;
}
.login-button:hover { background-color: #434190; }
.footer-bar {
position: absolute; bottom: 0; left: 0; width: 100%; height: 50px;
background-color: #ffffff; opacity: 0.7; z-index: 100;
display: flex; justify-content: center; align-items: center; gap: 20px;
}
/* 대시보드 헤더 */
header {
background-color: #333;
padding: 0 1rem;
flex-shrink: 0;
}
.tabs { display: flex; justify-content: space-between; align-items: center; }
.tab-button {
background: none; border: none; color: #aaa;
padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold;
}
.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; }
.logout-button {
background-color: #d9534f; color: white; border: none;
padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold;
}
/* 메인 컨텐츠 (다크 테마) */
main {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
background-color: #1a1a1a;
color: #e0e0e0;
}
.tab-content { display: none; }
.tab-content.active {
display: flex; flex-direction: column; flex-grow: 1; min-height: 0;
}
/* Video 스타일 */
.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; }
.video-player {
width: 100%; flex-grow: 1; min-height: 0;
background-color: #111; color: #eee;
display: flex; flex-direction: row; gap: 10px;
padding: 10px; box-sizing: border-box; border-radius: 4px; position: relative;
}
#video-wrap { flex: 2; display: flex; flex-direction: column; gap: 4px; position: relative; }
#frame { flex: 1; min-height: 0; max-width: 100%; background: #000; border: 1px solid #333; z-index: 1; }
#det-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; z-index: 1; }
#det-list { flex: 1; font-size: 11px; background: #000; border: 1px solid #333; padding: 6px; overflow-y: auto; white-space: pre; }
#bbox-container { position: absolute; pointer-events: none; z-index: 5; top: 0; left: 0; }
.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
/* Segmented Control (모델 선택) */
.segmented-control-container {
position: absolute; top: 60px; left: 20px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 4px;
background-color: rgba(50, 50, 50, 0.7); padding: 4px; border-radius: 6px;
}
.segmented-control-container input[type="radio"] { display: none; }
.segmented-control-button {
display: inline-block; padding: 6px 10px; font-size: 0.85rem;
color: #eee; background-color: #555; border: 1px solid #777; border-radius: 4px;
cursor: pointer; user-select: none;
}
.segmented-control-container input[type="radio"]:checked + .segmented-control-button {
background-color: #5a67d8; color: white; border-color: #434190; font-weight: bold;
}
/* Video Overlays */
.video-overlay {
position: absolute; z-index: 10;
background-color: rgba(0, 0, 0, 0.5); color: #ffffff;
padding: 4px 8px; border-radius: 4px; font-weight: bold; pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.video-overlay.top-right { top: 100px; right: 10px; font-size: 60px; }
.video-overlay.bottom-right { bottom: 70px; right: 10px; font-size: 28px; color: #FFEE00; }
/* ========================================================== */
/* [설정 탭 - AI 모델 & 관심 인물 관리 스타일] */
/* ========================================================== */
.toolbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 1rem;
}
.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; }
.right-actions { margin-left: auto; display: flex; gap: 10px; }
/* 공통 테이블 스타일 */
.model-table {
width: 100%; border-collapse: collapse; background: #2a2a2a;
}
.model-table th, .model-table td {
border: 1px solid #444; padding: 0.8rem 1rem; vertical-align: middle;
}
.model-table th { background-color: #333; text-align: center; }
.model-table td:first-child { text-align: center; } /* 번호 */
.model-table td { text-align: center; } /* 기본 중앙 정렬 */
/* 파일명 표시 input */
#file-name-display {
font-size: 0.9rem; color: #ccc; background-color: #2a2a2a;
border: 1px solid #444; padding: 5px 10px; border-radius: 4px;
min-width: 300px; text-align: left; font-style: italic;
}
.hidden-file-input { display: none; }
.hidden { display: none !important; }
/* 버튼 스타일 */
.btn-browse {
cursor: pointer; border-radius: 4px; border: 1px solid #666;
padding: 5px 10px; font-size: 14px; background-color: #4f4f4f; color: #e0e0e0;
}
.btn-browse:hover { background-color: #5a5a5a; }
.btn-action { background-color: #4CAF50; color: white; border: none; }
.btn-delete { background-color: #f44336; color: white; border: none; }
.refresh-button { margin-left: auto; }
/* 흰색 배경 버튼 (UI 디자인 반영) */
.btn-white {
background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold;
}
.btn-white:hover { background-color: #f0f0f0; }
/* 구분선 */
.divider {
height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none;
}
/* ========================================================== */
/* [신규] 관심 인물(POI) 관리 전용 스타일 */
/* ========================================================== */
/* 빈 목록 메시지 */
.empty-message {
text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem;
}
/* 미리보기 컨테이너 (세로 정렬) */
.preview-container {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
/* 개별 이미지 아이템 (이미지 + 삭제버튼) */
.preview-item {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 4px;
}
/* 미리보기 이미지 크기 */
.poi-preview {
width: 50px; height: 50px; object-fit: cover;
border-radius: 4px; border: 1px solid #555; display: block; margin: 0 auto;
cursor: pointer; /* 클릭 가능 표시 */
transition: 0.3s;
}
.poi-preview:hover { opacity: 0.7; }
/* 삭제 버튼 공통 */
.btn-delete-small {
background-color: #fff; border: 1px solid #ccc; color: #333;
padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px;
border-radius: 2px;
}
.btn-delete-small:hover { background-color: #eee; }
/* 모달 관련 스타일 (기존 유지) */
.modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex; justify-content: center; align-items: center; z-index: 2000;
}
.modal-content {
background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333;
}
.modal-content h3 { margin-top: 0; margin-bottom: 2rem; text-align: left; font-size: 1.2rem; font-weight: bold; }
.modal-body { margin-bottom: 2rem; }
.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; text-align: center; box-sizing: border-box; }
.modal-actions { display: flex; justify-content: center; gap: 20px; }
.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; }
.btn-modal-confirm { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-cancel { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-confirm:hover, .btn-modal-cancel:hover { background-color: #eee; }
/* ========================================================== */
/* [신규] 이미지 뷰어 모달 스타일 */
/* ========================================================== */
/* 뷰어 모달 이미지 */
.modal-image {
margin: auto;
display: block;
max-width: 90%;
max-height: 90%;
border-radius: 5px;
animation-name: zoom;
animation-duration: 0.4s;
}
/* 확대 애니메이션 */
@keyframes zoom {
from {transform:scale(0)}
to {transform:scale(1)}
}
/* 닫기 버튼 (X) */
.close-viewer {
position: absolute;
top: 20px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.close-viewer:hover,
.close-viewer:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
#info {
display: flex;
align-items: center;
gap: 15px;
background-color: rgba(0, 0, 0, 0.6); /* 가독성을 위한 반투명 배경 */
padding: 8px 12px;
border-radius: 4px;
position: absolute;
top: 10px;
left: 10px;
z-index: 20; /* 오버레이보다 위 */
color: #fff;
font-size: 14px;
}
/* [추가됨] 토글 스위치 컨테이너 */
.panel-toggle-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
border-left: 1px solid #555;
padding-left: 15px;
}
.toggle-text {
font-size: 0.85rem;
color: #ddd;
font-weight: bold;
}
/* [추가됨] 스위치(Checkbox) 디자인 */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
/* 체크되었을 때 색상 (기존 버튼 색상과 통일) */
input:checked + .slider {
background-color: #5a67d8;
}
input:checked + .slider:before {
transform: translateX(14px);
}
/* [추가됨] 패널 숨김 처리용 클래스 */
/* !important를 사용하여 flex 속성을 덮어씌웁니다 */
.collapsed-panel {
display: none !important;
}
/*2025.11.25 16:16*/

@ -29,6 +29,8 @@ document.addEventListener('DOMContentLoaded', () => {
contentVideo.classList.add('active');
contentModels.classList.remove('active');
updateBboxContainerPosition();
// Mission View 탭 활성화 시 Zoom In 토글 보이기
updateZoomInToggleVisibility(true);
});
tabModels.addEventListener('click', () => {
@ -38,6 +40,8 @@ document.addEventListener('DOMContentLoaded', () => {
contentVideo.classList.remove('active');
loadModelList();
loadPoiList();
// Settings 탭 활성화 시 Zoom In 토글 숨기기
updateZoomInToggleVisibility(false);
});
}
@ -48,6 +52,10 @@ document.addEventListener('DOMContentLoaded', () => {
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', () => {
@ -55,6 +63,7 @@ document.addEventListener('DOMContentLoaded', () => {
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', () => {
@ -62,29 +71,78 @@ document.addEventListener('DOMContentLoaded', () => {
btnSummary.classList.remove('active');
if(summaryListEl) summaryListEl.classList.add('hidden');
if(logListEl) logListEl.classList.remove('hidden');
if(logIntervalContainer) logIntervalContainer.classList.remove('hidden');
});
}
// 로그 업데이트 함수 (최신 50개 유지)
function updateLogPanel(textData) {
if (!logListEl) return;
function flushLogBuffer() {
if (logBuffer.length > 0) {
const fragment = document.createDocumentFragment();
logBuffer.forEach(textData => {
const row = createLogEntry(textData);
fragment.appendChild(row);
});
logListEl.appendChild(fragment);
logBuffer = [];
const now = new Date();
const timeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
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';
// JSON 문자열이 너무 길 수 있으므로 적절히 자르거나 그대로 표시
// 가독성을 위해 시간 + 데이터 표시
row.innerHTML = `<span class="log-time">[${timeStr}]</span> <span class="log-msg">${textData}</span>`;
return row;
}
// 최신 로그가 위로 오게 하거나 아래로 오게 할 수 있음 (여기선 위로 쌓음)
logListEl.prepend(row);
// 메모리 관리를 위해 50개 넘어가면 삭제
if (logListEl.children.length > 50) {
logListEl.removeChild(logListEl.lastChild);
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;
}
}
@ -104,6 +162,28 @@ document.addEventListener('DOMContentLoaded', () => {
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 => {
@ -129,6 +209,16 @@ document.addEventListener('DOMContentLoaded', () => {
.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) {
@ -138,12 +228,11 @@ document.addEventListener('DOMContentLoaded', () => {
const def = MODEL_DEFINITIONS[modelCode];
if(!def) return;
// 1. 기본 'All' 체크박스 생성
const allLabel = document.createElement('label');
allLabel.className = 'filter-check';
const allInput = document.createElement('input');
allInput.type = 'checkbox';
allInput.checked = true; // 기본적으로 All은 체크됨
allInput.checked = true;
allLabel.appendChild(allInput);
allLabel.append(' all');
@ -156,20 +245,18 @@ document.addEventListener('DOMContentLoaded', () => {
const classInputs = [];
// 2. 모델별 클래스 필터 생성
def.classes.forEach(cls => {
const label = document.createElement('label');
label.className = 'filter-check';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true; // All이 체크되어 있으므로 기본적으로 모두 체크
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;
@ -177,36 +264,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// All 체크박스 변경 시 로직
allInput.addEventListener('change', () => {
const isChecked = allInput.checked;
classInputs.forEach(inp => inp.checked = isChecked);
updateActiveFilters(classInputs);
});
// 3. [신규 기능] LPR(차량 인식) 모드일 때만 'Zoom In' 체크박스 추가
if (modelCode === 'LPR') {
const zoomLabel = document.createElement('label');
zoomLabel.className = 'filter-check filter-push-right'; // 우측 정렬 클래스 적용
const zoomInput = document.createElement('input');
zoomInput.type = 'checkbox';
zoomInput.id = 'zoom-toggle';
zoomInput.checked = false; // 기본값: 선택 안 됨
zoomInput.addEventListener('change', (e) => {
if(e.target.checked) {
console.log("Zoom In Activated");
} else {
console.log("Zoom In Deactivated");
}
});
zoomLabel.appendChild(zoomInput);
zoomLabel.append(' Zoom In');
filterBar.appendChild(zoomLabel);
}
updateActiveFilters(classInputs);
}
@ -218,6 +281,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
updateFilterBar('OBJDET');
updateZoomInToggleVisibility(true);
// =================================================
// 2. 비디오 & 웹소켓 & Summary 패널
@ -243,7 +307,6 @@ document.addEventListener('DOMContentLoaded', () => {
const fpsDisplayEl = document.getElementById("fps-display");
const resolutionEl = document.getElementById("resolution-display");
const connMsgEl = document.getElementById("connection-msg");
// summaryListEl은 상단에서 정의됨
let ctx = null;
if (canvasEl) ctx = canvasEl.getContext('2d');
@ -265,7 +328,6 @@ document.addEventListener('DOMContentLoaded', () => {
ws.onmessage = (event) => {
const data = event.data;
if (typeof data === "string") {
// [Log 기능 연결] 문자열(JSON) 데이터 수신 시 로그 패널에 출력
updateLogPanel(data);
try {
@ -310,6 +372,7 @@ document.addEventListener('DOMContentLoaded', () => {
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;
@ -320,7 +383,6 @@ document.addEventListener('DOMContentLoaded', () => {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
// 필터링
if (!activeClassFilters.has(displayClassName)) {
return;
}
@ -346,6 +408,7 @@ document.addEventListener('DOMContentLoaded', () => {
boxDiv.style.height = `${screenH}px`;
boxDiv.style.borderColor = boxColor;
if (shouldShowLabel) {
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px';
@ -356,8 +419,9 @@ document.addEventListener('DOMContentLoaded', () => {
label.style.fontWeight = 'bold';
label.style.padding = '1px 4px';
label.textContent = displayClassName;
boxDiv.appendChild(label);
}
bboxContainerEl.appendChild(boxDiv);
});
@ -365,7 +429,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
function updateSummaryPanel(counts) {
if(!summaryListEl) return;
if (!summaryListEl) return;
summaryListEl.innerHTML = '';
const keys = Object.keys(counts);
if (keys.length === 0) {
@ -375,7 +439,9 @@ document.addEventListener('DOMContentLoaded', () => {
keys.forEach(key => {
const row = document.createElement('div');
row.className = 'summary-row';
row.innerHTML = `<span class="label">${key}</span><span class="count">${counts[key]}</span>`;
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">${counts[key]}</span>`;
summaryListEl.appendChild(row);
});
}
@ -391,9 +457,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
// =================================================
// [신규] Sidebar Resizer (Drag Handle) Logic
// =================================================
const resizer = document.getElementById('drag-handle');
const rightPanel = document.querySelector('.mission-right');
@ -401,25 +464,15 @@ document.addEventListener('DOMContentLoaded', () => {
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
// 드래그 시작 시점의 위치와 너비 저장
const startX = e.clientX;
const startRightWidth = rightPanel.getBoundingClientRect().width;
// 드래그 중
const onMouseMove = (e) => {
// 오른쪽 패널은 왼쪽에 있으므로, 왼쪽으로 가면 너비가 늘어나고 오른쪽으로 가면 줄어듦
// Delta = 현재 마우스 X - 시작 X
// 이동한 만큼 너비에서 뺌 (왼쪽 드래그 -> 음수 -> 너비 증가)
const newWidth = startRightWidth - (e.clientX - startX);
// 너비 적용 (CSS의 min-width, max-width가 있어서 제한됨)
rightPanel.style.width = `${newWidth}px`;
// **중요**: 레이아웃이 변경되면 캔버스 오버레이(BBox) 위치가 어긋나므로 재계산 필요
updateBboxContainerPosition();
};
// 드래그 종료
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
@ -430,9 +483,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// =================================================
// 3. 모델 파일 관리 로직
// =================================================
function loadModelList() {
const tableBody = document.querySelector('.model-table tbody');
if (!tableBody) return;
@ -497,10 +547,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (refreshButton) refreshButton.addEventListener('click', loadModelList);
// =================================================
// 4. 관심 인물(POI) 관리 로직
// =================================================
const poiTableBody = document.querySelector('#poi-table tbody');
const poiEmptyMsg = document.getElementById('poi-empty-msg');
const btnPoiRegister = document.getElementById('btn-poi-register');
@ -585,3 +631,5 @@ document.addEventListener('DOMContentLoaded', () => {
loadModelList();
});
//2025-12-09 14:41

@ -14,7 +14,17 @@
<button id="tab-video" class="tab-button active">Mission View</button>
<button id="tab-models" class="tab-button">Settings</button>
</div>
<div id="header-right-actions">
<div id="show-label-container" style="margin-right: 15px;">
<input type="checkbox" id="show-label-toggle">
<label for="show-label-toggle" style="color: white;">Show detect label</label>
</div>
<div id="zoom-in-container">
<input type="checkbox" id="zoom-toggle">
<label for="zoom-toggle">Zoom In</label>
</div>
<button id="logout-button" class="logout-button">Logout</button>
</div>
</nav>
</header>
@ -25,46 +35,69 @@
<aside class="sidebar-nav">
<div class="nav-item active" data-model="OBJDET">
<div class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path fill="currentColor" d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px">
<path fill="currentColor"
d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/>
</svg>
</div>
<span>객체 탐지</span>
</div>
<div class="nav-item" data-model="FIRE">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19.48 12.35c-1.57-4.08-7.16-4.3-5.81-10.23c.1-.44-.37-.78-.75-.55C9.29 3.71 6.68 8 8.87 13.62c.18.46-.36.89-.75.59c-1.81-1.37-2-3.34-1.84-4.75c.06-.52-.62-.77-.91-.34C4.69 10.16 4 11.84 4 14.37c.38 5.6 5.11 7.32 6.81 7.54c2.43.31 5.06-.14 6.95-1.87c2.98-2.73 2.82-6.21 1.72-7.69z"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M19.48 12.35c-1.57-4.08-7.16-4.3-5.81-10.23c.1-.44-.37-.78-.75-.55C9.29 3.71 6.68 8 8.87 13.62c.18.46-.36.89-.75.59c-1.81-1.37-2-3.34-1.84-4.75c.06-.52-.62-.77-.91-.34C4.69 10.16 4 11.84 4 14.37c.38 5.6 5.11 7.32 6.81 7.54c2.43.31 5.06-.14 6.95-1.87c2.98-2.73 2.82-6.21 1.72-7.69z"/>
</svg>
</div>
<span>화재 감지</span>
</div>
<div class="nav-item" data-model="CROWD">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM5.8 14h12.4c1.3 0 2.4-.8 2.8-2L23 6l-1.7-.5l-1.2 3.6l-1.3-1c.2-.5.2-1.1.2-1.7c0-2.8-2.2-5-5-5S9 3.6 9 6.4c0 .5.1 1.1.2 1.6l-1.2 1l-1.3-3.7L5 5.8l2 6.1c.4 1.3 1.5 2.1 2.8 2.1zM11 16v6h2v-6h-2z"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM5.8 14h12.4c1.3 0 2.4-.8 2.8-2L23 6l-1.7-.5l-1.2 3.6l-1.3-1c.2-.5.2-1.1.2-1.7c0-2.8-2.2-5-5-5S9 3.6 9 6.4c0 .5.1 1.1.2 1.6l-1.2 1l-1.3-3.7L5 5.8l2 6.1c.4 1.3 1.5 2.1 2.8 2.1zM11 16v6h2v-6h-2z"/>
</svg>
</div>
<span>군중 위험</span>
<span>군중 위험 인지</span>
</div>
<div class="nav-item" data-model="FACEATTR">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 11.75c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zm6 0c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8c0-.29.02-.58.05-.86c2.36-1.05 4.23-2.98 5.21-5.37c1.12 2.66 3.04 4.81 5.48 6.02c.09.28.16.57.16.87c-2.9 0-5.51 1.54-7.02 3.86c1.23.94 2.76 1.48 4.42 1.48z"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M9 11.75c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zm6 0c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8c0-.29.02-.58.05-.86c2.36-1.05 4.23-2.98 5.21-5.37c1.12 2.66 3.04 4.81 5.48 6.02c.09.28.16.57.16.87c-2.9 0-5.51 1.54-7.02 3.86c1.23.94 2.76 1.48 4.42 1.48z"/>
</svg>
</div>
<span>얼굴 인식</span>
</div>
<div class="nav-item" data-model="ABNORM">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8l2.1 2v6h2v-7.5l-2.1-2l.6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1c-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7z"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8l2.1 2v6h2v-7.5l-2.1-2l.6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1c-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7z"/>
</svg>
<span style="position:absolute; right:10px; top:10px; font-weight:bold;">?</span>
</div>
<span>이상 행동</span>
<span>이상 행동 감지</span>
</div>
<div class="nav-item" data-model="LPR">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.85 7h10.29l1.08 3.11H5.77L6.85 7zM19 17H5v-5h14v5z"/><circle cx="7.5" cy="14.5" r="1.5"/><circle cx="16.5" cy="14.5" r="1.5"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.85 7h10.29l1.08 3.11H5.77L6.85 7zM19 17H5v-5h14v5z"/>
<circle cx="7.5" cy="14.5" r="1.5"/>
<circle cx="16.5" cy="14.5" r="1.5"/>
</svg>
</div>
<span>차량 인식</span>
<span>차량 번호판 인식</span>
</div>
<div class="nav-item" data-model="VIPTRACK">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h4V3H3v6h2V5zm10-2v2h4v4h2V3h-6zm4 14h-4v2h6v-6h-2v4zM5 19v-4H3v6h6v-2H5zM12 7a5 5 0 1 0 5 5a5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3a3 3 0 0 1-3 3z"/></svg>
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M5 5h4V3H3v6h2V5zm10-2v2h4v4h2V3h-6zm4 14h-4v2h6v-6h-2v4zM5 19v-4H3v6h6v-2H5zM12 7a5 5 0 1 0 5 5a5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3a3 3 0 0 1-3 3z"/>
</svg>
</div>
<span>관심 인물</span>
<span>관심 인물 추적</span>
</div>
</aside>
@ -101,6 +134,12 @@
<span id="btn-summary" class="active">Summary</span>
<span id="btn-log">Log</span>
</div>
<div id="log-interval-container" class="hidden"
style="margin-left: auto; display: flex; align-items: center; margin-right: 10px;">
<input type="checkbox" id="log-interval-toggle" checked>
<label for="log-interval-toggle"
style="color: white; font-size: 12px; margin-left: 5px; cursor: pointer;">1초 주기로 보기</label>
</div>
<div class="summary-content" id="summary-list">
<div class="summary-row">
<span class="label">Waiting...</span>
@ -137,35 +176,45 @@
<td>객체 탐지/ 분류</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FIRE">
<td>2</td>
<td>화재(불꽃, 연기) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="ABNORM">
<td>3</td>
<td>이상행동(쓰러짐, 폭행) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FACEATTR">
<td>4</td>
<td>얼굴/ 인상착의 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td><button class="btn-delete">삭제</button></td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="LPR">
<td>5</td>
<td>차량 번호판/ 차종 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td><button class="btn-delete">삭제</button></td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
</tbody>
</table>
@ -218,3 +267,5 @@
<script src="app.js"></script>
</body>
</html>
<!--//2025-12-09 14:41-->

@ -120,6 +120,23 @@ header {
padding: 0.3rem 1rem; border-radius: 4px; font-weight: bold; cursor: pointer;
}
/* [신규] 헤더 우측 메뉴 스타일 */
#header-right-actions {
display: flex;
align-items: center;
gap: 15px; /* 로그아웃 버튼과의 간격 */
}
#zoom-in-container {
color: #ffffff;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 5px;
}
#zoom-in-container input[type="checkbox"] {
transform: scale(1.2); /* 체크박스 크기 약간 키움 */
}
/* ========================================================== */
/* 4. 메인 컨텐츠 영역 (Main Layout) */
/* ========================================================== */
@ -165,12 +182,7 @@ main {
min-width: 0; /* Flexbox overflow fix */
}
.filter-push-right {
margin-left: auto; /* Flex 컨테이너에서 우측 끝으로 밀어냄 */
margin-right: 10px;
font-weight: bold;
color: #ffd700; /* 눈에 띄게 노란색 계열 강조 (선택 사항) */
}
/* .filter-push-right - 삭제됨 */
/* 상단 필터 바 */
.filter-bar {
@ -378,3 +390,6 @@ main {
.modal-image { margin: auto; display: block; max-width: 90%; max-height: 90%; border-radius: 5px; animation-name: zoom; animation-duration: 0.4s; }
@keyframes zoom { from {transform:scale(0)} to {transform:scale(1)} }
.close-viewer { position: absolute; top: 20px; right: 35px; color: #f1f1f1; font-size: 40px; font-weight: bold; transition: 0.3s; cursor: pointer; z-index: 2001; }
/*//2025-12-09 14:41*/
Loading…
Cancel
Save