From 8600ed534b07d6bcbef65135f2be30effd9c1ced Mon Sep 17 00:00:00 2001 From: dongjin kim Date: Tue, 9 Dec 2025 16:26:50 +0900 Subject: [PATCH] Change Dashboard features. --- public/_app.js | 610 ----------------------------------------- public/_dashboard.html | 189 ------------- public/_index.html | 79 ------ public/_style.css | 431 ----------------------------- public/app.js | 208 ++++++++------ public/dashboard.html | 87 ++++-- public/style.css | 29 +- 7 files changed, 219 insertions(+), 1414 deletions(-) delete mode 100644 public/_app.js delete mode 100644 public/_dashboard.html delete mode 100644 public/_index.html delete mode 100644 public/_style.css diff --git a/public/_app.js b/public/_app.js deleted file mode 100644 index e275df3..0000000 --- a/public/_app.js +++ /dev/null @@ -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 ` -
- - -
- `; - }).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}'을(를) 완전히 삭제하시겠습니까?\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(); -}); \ No newline at end of file diff --git a/public/_dashboard.html b/public/_dashboard.html deleted file mode 100644 index d2f3bb1..0000000 --- a/public/_dashboard.html +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - Dashboard - AI Drone System - - - -
- -
- -
-
-
-
-
-
- 연결 시도 중... - -
- - Detections -
-
- - - -
-
-
-
Detections
-
-
- -
- - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
- - - - 선택된 파일 없음 - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
번호AI Model 역할파일명현재 버전삭제
1객체 탐지/ 분류-v1.0 - -
2화재(불꽃, 연기) 감지-v1.0 - -
3이상행동(쓰러짐, 폭행) 감지-v1.0 - -
4얼굴/ 인상착의 인식-- - -
5차량 번호판/ 차종 인식-- - -
- -
- -
- -
- - -
-
- - - - - - - - - - - - -
번호관심인물명미리 보기파일 등록
- -
-
- - - - - - - - \ No newline at end of file diff --git a/public/_index.html b/public/_index.html deleted file mode 100644 index 6a0bbb7..0000000 --- a/public/_index.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - AI Drone System - Login - - - - -

AI Mission Camera Console for Drones

- -
-

Login

- - -
-
- 👤 - -
-
- 🔒 - -
- -
-
- - - - - - \ No newline at end of file diff --git a/public/_style.css b/public/_style.css deleted file mode 100644 index a7b0d7d..0000000 --- a/public/_style.css +++ /dev/null @@ -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*/ \ No newline at end of file diff --git a/public/app.js b/public/app.js index 7fe9c49..cbc7b9c 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = `[${timeStr}] ${textData}`; + return row; + } + - // 최신 로그가 위로 오게 하거나 아래로 오게 할 수 있음 (여기선 위로 쌓음) - logListEl.prepend(row); + function updateLogPanel(textData) { + if (!logListEl) return; - // 메모리 관리를 위해 50개 넘어가면 삭제 - if (logListEl.children.length > 50) { - logListEl.removeChild(logListEl.lastChild); + 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,18 +408,20 @@ document.addEventListener('DOMContentLoaded', () => { 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 = '#000'; - label.style.fontSize = '11px'; - label.style.fontWeight = 'bold'; - label.style.padding = '1px 4px'; - label.textContent = displayClassName; - - boxDiv.appendChild(label); + if (shouldShowLabel) { + const label = document.createElement('div'); + label.style.position = 'absolute'; + label.style.top = '-20px'; + label.style.left = '-2px'; + label.style.backgroundColor = boxColor; + label.style.color = '#000'; + label.style.fontSize = '11px'; + label.style.fontWeight = 'bold'; + label.style.padding = '1px 4px'; + label.textContent = displayClassName; + boxDiv.appendChild(label); + } + bboxContainerEl.appendChild(boxDiv); }); @@ -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 = `${key}${counts[key]}`; + const color = getBoxColor(key); + const colorBox = ``; + row.innerHTML = `${colorBox}${key}${counts[key]}`; 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'); @@ -584,4 +630,6 @@ document.addEventListener('DOMContentLoaded', () => { } loadModelList(); -}); \ No newline at end of file +}); + +//2025-12-09 14:41 \ No newline at end of file diff --git a/public/dashboard.html b/public/dashboard.html index da10767..75ce9f3 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -14,7 +14,17 @@ - +
+
+ + +
+
+ + +
+ +
@@ -25,46 +35,69 @@ @@ -101,6 +134,12 @@ Summary Log +
Waiting... @@ -137,35 +176,45 @@ 객체 탐지/ 분류 - v1.0 - + + + 2 화재(불꽃, 연기) 감지 - v1.0 - + + + 3 이상행동(쓰러짐, 폭행) 감지 - v1.0 - + + + 4 얼굴/ 인상착의 인식 - - - + + + 5 차량 번호판/ 차종 인식 - - - + + + @@ -217,4 +266,6 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/public/style.css b/public/style.css index b634813..7c0f21b 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { @@ -377,4 +389,7 @@ 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; } \ No newline at end of file +.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*/ \ No newline at end of file