|
|
|
|
// 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. 비디오 & 웹소켓 (좌표 그리기 로직 추가됨)
|
|
|
|
|
// =================================================
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// [신규] 캔버스 영상의 배율(scale)과 여백(offset)을 저장하여 좌표 변환에 사용
|
|
|
|
|
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}`;
|
|
|
|
|
} else if (meta.type === "det") {
|
|
|
|
|
// 탐지 정보가 오면 박스를 그림
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// r: 축소/확대 비율, dx/dy: 중앙 정렬을 위한 여백
|
|
|
|
|
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를 그리는 함수
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
const cls = it.cls !== undefined ? it.cls : '?';
|
|
|
|
|
const tag = it.tag !== undefined ? it.tag : '';
|
|
|
|
|
|
|
|
|
|
// 우측 정보창에 텍스트 로그 추가
|
|
|
|
|
outputLines.push(`#${i} | Class:${cls} | Tag:${tag} | Box:[${x1.toFixed(1)}, ${y1.toFixed(1)}, ${x2.toFixed(1)}, ${y2.toFixed(1)}]`);
|
|
|
|
|
|
|
|
|
|
// 좌표 변환: 원본 좌표 -> 캔버스 화면 좌표
|
|
|
|
|
// 공식: 화면좌표 = 여백 + (원본좌표 * 배율)
|
|
|
|
|
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'; // style.css에 정의된 붉은 박스 스타일 사용
|
|
|
|
|
boxDiv.style.left = `${screenX}px`;
|
|
|
|
|
boxDiv.style.top = `${screenY}px`;
|
|
|
|
|
boxDiv.style.width = `${screenW}px`;
|
|
|
|
|
boxDiv.style.height = `${screenH}px`;
|
|
|
|
|
|
|
|
|
|
// 박스 위 라벨 (선택 사항)
|
|
|
|
|
const label = document.createElement('div');
|
|
|
|
|
label.style.position = 'absolute';
|
|
|
|
|
label.style.top = '-16px';
|
|
|
|
|
label.style.left = '0';
|
|
|
|
|
label.style.backgroundColor = 'red';
|
|
|
|
|
label.style.color = 'white';
|
|
|
|
|
label.style.fontSize = '10px';
|
|
|
|
|
label.style.padding = '1px 3px';
|
|
|
|
|
label.style.whiteSpace = 'nowrap';
|
|
|
|
|
label.textContent = `C:${cls} T:${tag}`;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 초기 실행
|
|
|
|
|
updateModelDisplay();
|
|
|
|
|
if (canvasEl) connect();
|
|
|
|
|
window.addEventListener('resize', updateBboxContainerPosition);
|
|
|
|
|
loadPoiList();
|
|
|
|
|
loadModelList();
|
|
|
|
|
});
|