Files
ITAM/map_editor.html
Taehoon 590ddd0e85 feat: enhance map editor, refine location view, and update image assets
- Map Editor: Add box numbering (drawing/placed) and set default file
- Location View: Refine mouse interaction in view mode (readonly)
- Assets: Add MDF room support and update server room directory structure
- Backend: Add map configuration API for real-time saving
2026-06-01 14:00:45 +09:00

308 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>ITAM Map Coordinate Editor v3.0</title>
<style>
:root {
--primary: #1E5149;
--bg: #f5f5f5;
}
body { font-family: sans-serif; margin: 0; display: flex; height: 100vh; background: var(--bg); overflow: hidden; }
/* Left Sidebar: File Explorer */
.file-sidebar { width: 260px; background: white; border-right: 1px solid #ddd; display: flex; flex-direction: column; overflow-y: auto; }
.folder-item { padding: 10px 15px; background: #eee; font-weight: bold; font-size: 13px; border-bottom: 1px solid #ddd; color: var(--primary); }
.file-item { padding: 8px 25px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #f9f9f9; transition: background 0.2s; }
.file-item:hover { background: #f0f0f0; }
.file-item.active { background: var(--primary); color: white; font-weight: bold; }
/* Center: Editor Area */
.editor-container { flex: 1; position: relative; overflow: auto; padding: 20px; display: flex; align-items: center; justify-content: center; background: #e0e0e0; }
.img-wrapper { position: relative; display: inline-block; box-shadow: 0 0 30px rgba(0,0,0,0.3); background: white; line-height: 0; }
img {
display: block;
max-width: calc(100vw - 650px); /* 좌우 사이드바 제외 */
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar { width: 350px; background: white; border-left: 1px solid #ddd; display: flex; flex-direction: column; padding: 20px; box-shadow: -5px 0 15px rgba(0,0,0,0.05); }
h2 { margin-top: 0; color: var(--primary); font-size: 1.2rem; }
p { font-size: 0.85rem; color: #666; line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: 11px; color: #888; margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list { flex: 1; overflow-y: auto; margin-bottom: 15px; border: 1px solid #eee; border-radius: 4px; padding: 10px; background: #fafafa; }
.box-item { font-family: monospace; font-size: 11px; padding: 6px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
.box-item:hover { background: #fff; }
.btn-del { cursor: pointer; color: #ff4444; border: none; background: none; font-size: 16px; padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
button { padding: 12px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; transition: all 0.2s; }
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: #f0f0f0; color: #333; border: 1px solid #ccc; }
button:hover { filter: brightness(1.1); }
button:active { transform: scale(0.98); }
button:disabled { background: #ccc; cursor: not-allowed; }
/* Drawing Elements */
.draw-box { position: absolute; border: 2px solid #FF3D00; background: rgba(255, 61, 0, 0.2); pointer-events: none; z-index: 100; }
.placed-box { position: absolute; border: 1.5px solid var(--primary); background: rgba(30, 81, 73, 0.15); cursor: pointer; z-index: 50; }
.placed-box:hover { background: rgba(30, 81, 73, 0.4); border-color: #000; }
.placed-box.selected { border: 2.5px solid #FF3D00; z-index: 60; box-shadow: 0 0 10px rgba(255,61,0,0.5); }
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: var(--primary);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: #FF3D00;
background: rgba(255,255,255,0.8);
}
#save-status { margin-top: 8px; font-size: 11px; color: #27ae60; text-align: center; font-weight: bold; height: 14px; }
</style>
</head>
<body>
<!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar">
<div class="folder-item">IDC</div>
<div class="file-item active" data-path="img/location_photo/IDC/서관202.png">서관202.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관203.png">서관203.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관204.png">서관204.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관205.png">서관205.png</div>
<div class="file-item" data-path="img/location_photo/IDC/동관53.png">동관53.png</div>
<div class="file-item" data-path="img/location_photo/IDC/동관54.png">동관54.png</div>
<div class="folder-item">기술개발센터</div>
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_1.png">서버실_1.png</div>
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_2.png">서버실_2.png</div>
<div class="folder-item">한맥빌딩</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/7층_로비.png">7층_배치도(예시)</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_1.png">MDF_1.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_2.png">MDF_2.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_3.png">MDF_3.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_4.png">MDF_4.png</div>
</div>
<!-- Center: Main Editor -->
<div class="editor-container" id="container">
<div class="img-wrapper" id="wrapper">
<img src="img/location_photo/IDC/서관202.png" id="target-img" alt="Map Image">
</div>
</div>
<!-- Right: Control Panel -->
<div class="sidebar">
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
<div class="current-path" id="current-path">img/location_photo/IDC/서관202.png</div>
<p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
</p>
<div class="box-list" id="box-list"></div>
<div class="actions">
<button class="btn-secondary" onclick="clearAll()">전체 삭제</button>
<button id="btn-save-server" class="btn-primary" onclick="saveToServer()">서버에 즉시 저장</button>
<div id="save-status"></div>
</div>
</div>
<script>
const wrapper = document.getElementById('wrapper');
const img = document.getElementById('target-img');
const boxListEl = document.getElementById('box-list');
const pathLabel = document.getElementById('current-path');
const fileItems = document.querySelectorAll('.file-item');
const statusEl = document.getElementById('save-status');
const saveBtn = document.getElementById('btn-save-server');
let allMapConfig = {};
let boxes = [];
let isDrawing = false;
let startX, startY;
let currentBox = null;
// 1. 서버에서 기존 설정 로드
async function loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
allMapConfig = await res.json();
renderCurrentFile();
} catch (err) {
console.error('Failed to load config:', err);
}
}
function renderCurrentFile() {
const activeItem = document.querySelector('.file-item.active');
const activeFile = activeItem.dataset.path;
boxes = allMapConfig[activeFile] || [];
pathLabel.textContent = activeFile;
img.src = activeFile;
render();
}
// File Selection
fileItems.forEach(item => {
item.addEventListener('click', () => {
fileItems.forEach(i => i.classList.remove('active'));
item.classList.add('active');
renderCurrentFile();
});
});
// 2. 서버에 저장
async function saveToServer() {
const activeFile = document.querySelector('.file-item.active').dataset.path;
try {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: activeFile, boxes: boxes })
});
if (res.ok) {
allMapConfig[activeFile] = [...boxes];
statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = '서버에 즉시 저장';
}
}
wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDrawing = true;
const rect = wrapper.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
currentBox = document.createElement('div');
currentBox.className = 'draw-box';
currentBox.style.left = startX + 'px';
currentBox.style.top = startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (boxes.length + 1);
currentBox.appendChild(label);
wrapper.appendChild(currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
const rect = wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - startX;
const height = currentY - startY;
currentBox.style.width = Math.abs(width) + 'px';
currentBox.style.height = Math.abs(height) + 'px';
currentBox.style.left = (width > 0 ? startX : currentX) + 'px';
currentBox.style.top = (height > 0 ? startY : currentY) + 'px';
});
window.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
const width = parseFloat(currentBox.style.width);
const height = parseFloat(currentBox.style.height);
if (width > 3 && height > 3) {
const rect = wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2)
};
boxes.push(boxData);
render();
}
currentBox.remove();
currentBox = null;
});
function removeBox(index) {
boxes.splice(index, 1);
render();
}
function clearAll() {
if(confirm('모든 박스를 삭제할까요?')) {
boxes = [];
render();
}
}
function render() {
boxListEl.innerHTML = '';
const oldBoxes = wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
wrapper.appendChild(div);
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<span>#${i+1}: [${box.x}, ${box.y}]</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
`;
boxListEl.appendChild(item);
});
}
loadConfig();
</script>
</body>
</html>