- 서버 탭 복귀 시 최근 선택한 뷰 모드(목록/위치) 상태 유지 및 currentViewMode 상태 일원화 - 개인PC 대시보드 및 맵 에디터의 인라인 CSS 스타일을 공통 CSS 및 변수 클래스로 분리 및 가독성 개선 - Vite 멀티페이지 빌드 설정(vite.config.ts) 추가
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||
|
||
export class MapEditor {
|
||
private container: HTMLElement;
|
||
private wrapper: HTMLElement;
|
||
private img: HTMLImageElement;
|
||
private boxListEl: HTMLElement;
|
||
private pathLabel: HTMLElement;
|
||
private statusEl: HTMLElement;
|
||
private saveBtn: HTMLButtonElement;
|
||
private fileSidebar: HTMLElement;
|
||
|
||
private allMapConfig: Record<string, any[]> = {};
|
||
private boxes: any[] = [];
|
||
private isDrawing: boolean = false;
|
||
private startX: number = 0;
|
||
private startY: number = 0;
|
||
private currentBox: HTMLElement | null = null;
|
||
private currentPath: string = '';
|
||
private assetOptions: {id: string, name: string}[] = [];
|
||
|
||
constructor() {
|
||
this.container = document.getElementById('container')!;
|
||
this.wrapper = document.getElementById('wrapper')!;
|
||
this.img = document.getElementById('target-img') as HTMLImageElement;
|
||
this.boxListEl = document.getElementById('box-list')!;
|
||
this.pathLabel = document.getElementById('current-path')!;
|
||
this.statusEl = document.getElementById('save-status')!;
|
||
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
|
||
this.fileSidebar = document.getElementById('file-sidebar')!;
|
||
}
|
||
|
||
public async init() {
|
||
this.renderFileSidebar();
|
||
await this.loadConfig();
|
||
await this.loadAssets();
|
||
this.bindEvents();
|
||
this.selectFirstFile();
|
||
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
|
||
}
|
||
|
||
private async loadAssets() {
|
||
try {
|
||
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
|
||
const masterData = await res.json();
|
||
const allHw = [
|
||
...(masterData.pc || []),
|
||
...(masterData.server || []),
|
||
...(masterData.storage || []),
|
||
...(masterData.network || []),
|
||
...(masterData.equipment || []),
|
||
...(masterData.survey || []),
|
||
...(masterData.officeSupplies || []),
|
||
...(masterData.pcParts || [])
|
||
];
|
||
this.assetOptions = allHw.map(a => ({
|
||
id: a.id,
|
||
name: `[${a.asset_code}] ${a.asset_purpose || a.model_name || a.category}`
|
||
}));
|
||
} catch (err) {
|
||
console.error('Failed to load assets for mapping', err);
|
||
}
|
||
}
|
||
|
||
private renderFileSidebar() {
|
||
let html = '';
|
||
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
|
||
html += `<div class="folder-item">${bldg}</div>`;
|
||
Object.entries(details).forEach(([detail, paths]) => {
|
||
paths.forEach(path => {
|
||
const fileName = path.split('/').pop() || path;
|
||
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
|
||
});
|
||
});
|
||
});
|
||
this.fileSidebar.innerHTML = html;
|
||
|
||
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
|
||
item.classList.add('active');
|
||
this.renderCurrentFile();
|
||
});
|
||
});
|
||
}
|
||
|
||
private selectFirstFile() {
|
||
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
|
||
if (firstItem) {
|
||
firstItem.classList.add('active');
|
||
this.renderCurrentFile();
|
||
}
|
||
}
|
||
|
||
private async loadConfig() {
|
||
try {
|
||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||
this.allMapConfig = await res.json();
|
||
} catch (err) {
|
||
console.error('Failed to load config:', err);
|
||
}
|
||
}
|
||
|
||
private renderCurrentFile() {
|
||
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
|
||
if (!activeItem) return;
|
||
|
||
this.currentPath = activeItem.dataset.path || '';
|
||
this.boxes = this.allMapConfig[this.currentPath] || [];
|
||
this.pathLabel.textContent = this.currentPath;
|
||
this.img.src = this.currentPath;
|
||
this.render();
|
||
}
|
||
|
||
private bindEvents() {
|
||
this.wrapper.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
this.isDrawing = true;
|
||
const rect = this.wrapper.getBoundingClientRect();
|
||
this.startX = e.clientX - rect.left;
|
||
this.startY = e.clientY - rect.top;
|
||
|
||
this.currentBox = document.createElement('div');
|
||
this.currentBox.className = 'draw-box';
|
||
this.currentBox.style.left = this.startX + 'px';
|
||
this.currentBox.style.top = this.startY + 'px';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'box-label';
|
||
label.textContent = '#' + (this.boxes.length + 1);
|
||
this.currentBox.appendChild(label);
|
||
|
||
this.wrapper.appendChild(this.currentBox);
|
||
});
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!this.isDrawing || !this.currentBox) return;
|
||
const rect = this.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 - this.startX;
|
||
const height = currentY - this.startY;
|
||
|
||
this.currentBox.style.width = Math.abs(width) + 'px';
|
||
this.currentBox.style.height = Math.abs(height) + 'px';
|
||
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
|
||
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
if (!this.isDrawing || !this.currentBox) return;
|
||
this.isDrawing = false;
|
||
|
||
const width = parseFloat(this.currentBox.style.width);
|
||
const height = parseFloat(this.currentBox.style.height);
|
||
|
||
if (width > 3 && height > 3) {
|
||
const rect = this.wrapper.getBoundingClientRect();
|
||
const boxData = {
|
||
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
|
||
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
|
||
w: (width / rect.width * 100).toFixed(2),
|
||
h: (height / rect.height * 100).toFixed(2),
|
||
asset_id: null
|
||
};
|
||
this.boxes.push(boxData);
|
||
this.render();
|
||
}
|
||
|
||
this.currentBox.remove();
|
||
this.currentBox = null;
|
||
});
|
||
|
||
(window as any).removeBox = (index: number) => {
|
||
this.boxes.splice(index, 1);
|
||
this.render();
|
||
};
|
||
|
||
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
|
||
if(confirm('모든 박스를 삭제할까요?')) {
|
||
this.boxes = [];
|
||
this.render();
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||
}
|
||
|
||
private async saveToServer() {
|
||
if (!this.currentPath) return;
|
||
|
||
try {
|
||
this.saveBtn.disabled = true;
|
||
this.saveBtn.textContent = '저장 중...';
|
||
|
||
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
|
||
});
|
||
|
||
if (res.ok) {
|
||
this.allMapConfig[this.currentPath] = [...this.boxes];
|
||
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
|
||
setTimeout(() => this.statusEl.textContent = '', 3000);
|
||
} else {
|
||
alert('저장 실패!');
|
||
}
|
||
} catch (err) {
|
||
alert('서버 연결 오류!');
|
||
} finally {
|
||
this.saveBtn.disabled = false;
|
||
this.saveBtn.textContent = '서버에 즉시 저장';
|
||
}
|
||
}
|
||
|
||
private render() {
|
||
this.boxListEl.innerHTML = '';
|
||
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
|
||
oldBoxes.forEach(b => b.remove());
|
||
|
||
this.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);
|
||
|
||
this.wrapper.appendChild(div);
|
||
|
||
// Create asset options dropdown
|
||
let optionsHtml = '<option value="">-- 자산 매핑 안 됨 --</option>';
|
||
this.assetOptions.forEach(opt => {
|
||
const selected = box.asset_id === opt.id ? 'selected' : '';
|
||
optionsHtml += `<option value="${opt.id}" ${selected}>${opt.name}</option>`;
|
||
});
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'box-item';
|
||
item.innerHTML = `
|
||
<div class="box-header">
|
||
<span class="box-index">#${i+1}</span>
|
||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||
</div>
|
||
<div class="box-inputs margin-bottom">
|
||
<select data-index="${i}" data-prop="asset_id">
|
||
${optionsHtml}
|
||
</select>
|
||
</div>
|
||
<div class="box-inputs">
|
||
<div class="input-group">
|
||
<label>X</label>
|
||
<input type="number" step="0.01" value="${box.x}" data-index="${i}" data-prop="x">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>Y</label>
|
||
<input type="number" step="0.01" value="${box.y}" data-index="${i}" data-prop="y">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>W</label>
|
||
<input type="number" step="0.01" value="${box.w}" data-index="${i}" data-prop="w">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>H</label>
|
||
<input type="number" step="0.01" value="${box.h}" data-index="${i}" data-prop="h">
|
||
</div>
|
||
</div>
|
||
`;
|
||
this.boxListEl.appendChild(item);
|
||
});
|
||
|
||
// Add events to new inputs and selects
|
||
this.boxListEl.querySelectorAll('input, select').forEach(input => {
|
||
input.addEventListener('change', (e) => {
|
||
const target = e.target as HTMLInputElement | HTMLSelectElement;
|
||
const index = parseInt(target.dataset.index!);
|
||
const prop = target.dataset.prop!;
|
||
|
||
if (this.boxes[index]) {
|
||
if (prop === 'asset_id') {
|
||
this.boxes[index][prop] = target.value || null;
|
||
} else {
|
||
this.boxes[index][prop] = parseFloat(target.value).toFixed(2);
|
||
this.render(); // Re-render to update the map visual size
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|