Initial commit: MapinDrag - 근무 배치 시스템

Flask + Leaflet 기반 지도 드래그앤드롭 근무 배치 시스템
- 엑셀 업로드/파싱
- Leaflet 지도에 근무지/근무자 표시
- 드래그앤드롭으로 근무자 배치
- 엑셀 내보내기
This commit is contained in:
user01
2026-05-12 15:32:34 +09:00
commit ef9439a164
8 changed files with 692 additions and 0 deletions

View File

@@ -0,0 +1 @@
,gerd,vivoboxx,12.05.2026 15:27,file:///home/gerd/.config/libreoffice/4;

207
app.py Normal file
View File

@@ -0,0 +1,207 @@
import os
import io
import random
import requests
import openpyxl
from flask import Flask, render_template, request, jsonify, send_file
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
YONGSAN_CENTER = (37.5326, 126.9906)
store = {
'workers': [],
'workplaces': [],
'assignments': {},
'filename': None,
}
def geocode(address):
try:
url = 'https://nominatim.openstreetmap.org/search'
params = {'q': address.strip(), 'format': 'json', 'limit': 1}
headers = {'User-Agent': 'MapinDrag/1.0'}
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.ok and resp.json():
r = resp.json()[0]
return float(r['lat']), float(r['lon'])
except Exception:
pass
return None, None
def geocode_with_fallback(address):
lat, lng = geocode(f'{address}, 대한민국')
if lat is not None:
return lat, lng
lat, lng = geocode(f'서울특별시 용산구 {address}')
if lat is not None:
return lat, lng
lat, lng = geocode(f'용산구 {address}')
if lat is not None:
return lat, lng
jitter = random.uniform(-0.005, 0.005)
return YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter
@app.route('/')
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload():
file = request.files.get('file')
if not file:
return jsonify({'error': '파일이 없습니다.'}), 400
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
file.save(path)
wb = openpyxl.load_workbook(path, data_only=True)
workers = []
workplaces = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
headers = [str(c.value or '').strip() for c in ws[1]]
if '희망사항' in headers:
idx = {h: i for i, h in enumerate(headers)}
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row):
continue
name = str(row[idx.get('이름', 0)] or '').strip()
hope = str(row[idx.get('희망사항', 1)] or '').strip()
addr = str(row[idx.get('주소', 2)] or '').strip()
dob = str(row[idx.get('생년월일', 3)] or '').strip()
phone = str(row[idx.get('연락처', 4)] or '').strip()
if not name:
continue
lat, lng = geocode_with_fallback(addr)
workers.append({
'id': f'w{len(workers)}',
'name': name,
'hope': hope,
'address': addr,
'dob': dob,
'phone': phone,
'lat': lat,
'lng': lng,
})
if '희망사항' not in headers:
idx = {h: i for i, h in enumerate(headers)}
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row):
continue
name = str(row[idx.get('이름', 0)] or '').strip()
addr = str(row[idx.get('주소', 1)] or '').strip()
if not name:
continue
lat, lng = geocode_with_fallback(addr)
workplaces.append({
'id': f'p{len(workplaces)}',
'name': name,
'address': addr,
'lat': lat,
'lng': lng,
})
store['workers'] = workers
store['workplaces'] = workplaces
store['assignments'] = {}
store['filename'] = file.filename
return jsonify({
'workers': workers,
'workplaces': workplaces,
'assignments': {},
})
@app.route('/data')
def get_data():
return jsonify({
'workers': store['workers'],
'workplaces': store['workplaces'],
'assignments': store['assignments'],
})
@app.route('/assign', methods=['POST'])
def assign():
body = request.get_json()
worker_id = body.get('workerId')
workplace_id = body.get('workplaceId')
if not worker_id or not workplace_id:
return jsonify({'error': '잘못된 요청입니다.'}), 400
store['assignments'][worker_id] = workplace_id
return jsonify({'assignments': store['assignments']})
@app.route('/unassign', methods=['POST'])
def unassign():
body = request.get_json()
worker_id = body.get('workerId')
store['assignments'].pop(worker_id, None)
return jsonify({'assignments': store['assignments']})
@app.route('/reset', methods=['POST'])
def reset():
store['assignments'] = {}
return jsonify({'assignments': {}})
@app.route('/export')
def export():
wb = openpyxl.Workbook()
ws1 = wb.active
ws1.title = '배치 현황'
ws1.append(['이름', '연락처', '생년월일', '희망사항', '주소', '배치근무지', '근무지주소'])
wp_lookup = {wp['id']: wp for wp in store['workplaces']}
for w in store['workers']:
assigned = store['assignments'].get(w['id'])
wp_name = ''
wp_addr = ''
if assigned and assigned in wp_lookup:
wp_name = wp_lookup[assigned]['name']
wp_addr = wp_lookup[assigned]['address']
ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wp_name, wp_addr])
ws2 = wb.create_sheet('근무지별 배치')
ws2.append(['근무지명', '주소', '배치인원', '배치된근무자'])
for wp in store['workplaces']:
assigned_workers = [
w['name'] for w in store['workers']
if store['assignments'].get(w['id']) == wp['id']
]
count = len(assigned_workers)
ws2.append([wp['name'], wp['address'], count, ', '.join(assigned_workers)])
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
name = store.get('filename', 'export.xlsx')
base, ext = os.path.splitext(name)
download_name = f'{base}_배치결과{ext}'
return send_file(
buf,
as_attachment=True,
download_name=download_name,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
if __name__ == '__main__':
app.run(debug=True)

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
Flask==3.1.1
openpyxl==3.1.5
requests==2.32.3

BIN
sample_용산구.xlsx Normal file

Binary file not shown.

117
static/style.css Normal file
View File

@@ -0,0 +1,117 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; overflow: hidden; display: flex; flex-direction: column; background: #f0f2f5; }
#header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 20px; background: #fff; border-bottom: 1px solid #e0e0e0;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); z-index: 1000; min-height: 56px; gap: 12px; flex-wrap: wrap;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left h1 { font-size: 20px; color: #1a73e8; display: flex; align-items: center; gap: 8px; }
.header-subtitle { color: #666; font-size: 13px; }
.header-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;
border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
transition: all 0.2s; white-space: nowrap;
}
.btn-primary { background: #1a73e8; color: #fff; }
.btn-primary:hover { background: #1557b0; }
.btn-success { background: #10b981; color: #fff; }
.btn-success:hover { background: #059669; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-small { padding: 4px 10px; font-size: 11px; border: none; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.status-badge {
padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600;
background: #e5e7eb; color: #6b7280; min-width: 80px; text-align: center;
}
.status-badge.partial { background: #fef3c7; color: #d97706; }
.status-badge.complete { background: #d1fae5; color: #059669; }
#main { display: flex; flex: 1; overflow: hidden; }
#sidebar {
width: 340px; min-width: 340px; background: #fff; border-right: 1px solid #e0e0e0;
display: flex; flex-direction: column; overflow: hidden;
}
.sidebar-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px; border-bottom: 1px solid #e0e0e0;
}
.sidebar-header h2 { font-size: 15px; display: flex; align-items: center; gap: 6px; color: #333; }
.count-badge {
background: #1a73e8; color: #fff; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600;
}
.worker-list {
flex: 1; overflow-y: auto; padding: 8px;
}
.worker-card {
display: flex; gap: 10px; padding: 10px 12px; margin-bottom: 6px;
background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px;
cursor: grab; transition: all 0.2s; user-select: none;
}
.worker-card:hover { border-color: #1a73e8; box-shadow: 0 2px 8px rgba(26,115,232,0.12); }
.worker-card:active { cursor: grabbing; }
.worker-card.dragging { opacity: 0.4; }
.worker-card.assigned { background: #ecfdf5; border-color: #10b981; }
.worker-card-avatar {
width: 40px; height: 40px; border-radius: 50%; background: #e5e7eb;
display: flex; align-items: center; justify-content: center; color: #6b7280;
flex-shrink: 0; font-size: 16px;
}
.worker-card-avatar.assigned { background: #d1fae5; color: #059669; }
.worker-card-info { flex: 1; min-width: 0; }
.worker-card-name { font-weight: 600; font-size: 14px; color: #111; }
.worker-card-detail { font-size: 12px; color: #6b7280; margin-top: 1px; }
.worker-card-address { font-size: 11px; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.worker-card-hope { font-size: 11px; color: #d97706; margin-top: 2px; display: flex; align-items: center; gap: 3px; }
.worker-card-assignment { margin-top: 4px; }
.tag-assigned { font-size: 11px; color: #059669; font-weight: 500; display: flex; align-items: center; gap: 3px; }
.tag-unassigned { font-size: 11px; color: #9ca3af; }
#mapContainer {
flex: 1; position: relative; overflow: hidden;
}
#map { width: 100%; height: 100%; }
#dropOverlay {
position: absolute; top: 10px; left: 10px; right: 10px; bottom: 10px;
border: 3px dashed #1a73e8; border-radius: 12px; background: rgba(26,115,232,0.06);
display: none; align-items: center; justify-content: center;
font-size: 18px; color: #1a73e8; font-weight: 600; pointer-events: none; z-index: 500;
}
#dropOverlay.active { display: flex; }
#dropOverlay::before { content: '\f279'; font-family: 'Font Awesome 6 Free'; font-weight: 900; margin-right: 8px; }
.marker-icon {
display: flex; align-items: center; justify-content: center; border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.2); border: 2px solid #fff;
}
.worker-marker { background: #f59e0b; color: #fff; font-size: 14px; }
.worker-assigned-marker { background: #10b981; color: #fff; font-size: 14px; }
.workplace-marker {
background: #1a73e8; color: #fff; font-size: 18px; border-radius: 8px; width: 42px; height: 42px;
display: flex; align-items: center; justify-content: center;
}
.popup-content { font-size: 13px; line-height: 1.5; min-width: 150px; }
.wp-assignees { margin-top: 6px; padding-top: 6px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 12px 24px; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 500;
z-index: 9999; transition: all 0.3s; opacity: 1; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
white-space: nowrap;
}
.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(10px); pointer-events: none; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.toast.info { background: #1a73e8; }
.toast.warning { background: #f59e0b; color: #333; }

364
templates/index.html Normal file
View File

@@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MapinDrag - 근무 배치 시스템</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<div id="header">
<div class="header-left">
<h1><i class="fas fa-map-marked-alt"></i> MapinDrag</h1>
<span class="header-subtitle">서울특별시 용산구 근무 배치 시스템</span>
</div>
<div class="header-right">
<form id="uploadForm" enctype="multipart/form-data">
<label for="fileInput" class="btn btn-primary"><i class="fas fa-upload"></i> 엑셀 업로드</label>
<input type="file" id="fileInput" accept=".xlsx,.xls" hidden />
</form>
<button class="btn btn-success" id="exportBtn" onclick="exportExcel()"><i class="fas fa-download"></i> 내보내기</button>
<button class="btn btn-danger" id="resetBtn" onclick="resetAssignments()"><i class="fas fa-undo"></i> 초기화</button>
<span id="statusBadge" class="status-badge"></span>
</div>
</div>
<div id="main">
<div id="sidebar">
<div class="sidebar-header">
<h2><i class="fas fa-users"></i> 근무자</h2>
<span id="workerCount" class="count-badge">0</span>
</div>
<div id="workerList" class="worker-list"></div>
</div>
<div id="mapContainer">
<div id="map"></div>
<div id="dropOverlay">여기에 드롭하여 배치</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const YONGSAN = [37.5326, 126.9906];
let map, store;
const WORKER_ICON = L.divIcon({
className: 'marker-icon worker-marker',
html: '<i class="fas fa-user"></i>',
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -40],
});
const WORKER_ASSIGNED_ICON = L.divIcon({
className: 'marker-icon worker-assigned-marker',
html: '<i class="fas fa-user-check"></i>',
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -40],
});
const WORKPLACE_ICON = L.divIcon({
className: 'marker-icon workplace-marker',
html: '<i class="fas fa-building"></i>',
iconSize: [42, 42],
iconAnchor: [21, 42],
popupAnchor: [0, -40],
});
let markers = { workers: {}, workplaces: {} };
let assignmentLines = [];
function init() {
map = L.map('map').setView(YONGSAN, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map);
const mapContainer = document.getElementById('mapContainer');
mapContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.getElementById('dropOverlay').classList.add('active');
});
mapContainer.addEventListener('dragleave', (e) => {
document.getElementById('dropOverlay').classList.remove('active');
});
mapContainer.addEventListener('drop', (e) => {
e.preventDefault();
document.getElementById('dropOverlay').classList.remove('active');
const workerId = e.dataTransfer.getData('text/plain');
if (!workerId || !store) return;
const rect = map.getContainer().getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const latlng = map.containerPointToLatLng([x, y]);
const nearest = findNearestWorkplace(latlng.lat, latlng.lng);
if (!nearest) {
showToast('근무지 마커 근처에 드롭해주세요.', 'warning');
return;
}
assignWorker(workerId, nearest.id);
});
document.getElementById('fileInput').addEventListener('change', uploadExcel);
}
function findNearestWorkplace(lat, lng) {
const threshold = 0.002;
let best = null, bestDist = Infinity;
for (const wp of (store?.workplaces || [])) {
const d = Math.abs(wp.lat - lat) + Math.abs(wp.lng - lng);
if (d < threshold && d < bestDist) {
bestDist = d;
best = wp;
}
}
return best;
}
async function uploadExcel(e) {
const file = e.target.files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
showToast('업로드 중...', 'info');
try {
const resp = await fetch('/upload', { method: 'POST', body: form });
if (!resp.ok) throw new Error('업로드 실패');
store = await resp.json();
render();
showToast(`파일 로드 완료: ${file.name}`, 'success');
} catch (err) {
showToast('업로드 실패: ' + err.message, 'error');
}
e.target.value = '';
}
function render() {
if (!store) return;
clearMap();
renderWorkplaceMarkers();
renderWorkerMarkers();
renderWorkerList();
renderAssignmentLines();
updateBadges();
}
function clearMap() {
for (const key in markers.workers) {
map.removeLayer(markers.workers[key]);
}
for (const key in markers.workplaces) {
map.removeLayer(markers.workplaces[key]);
}
assignmentLines.forEach(l => map.removeLayer(l));
markers = { workers: {}, workplaces: {} };
assignmentLines = [];
}
function getWorkplaceAssigneesHtml(wpId) {
const assigned = store.workers.filter(w => store.assignments[w.id] === wpId);
if (assigned.length === 0) return '<div class="wp-assignees" style="color:#9ca3af;">배정된 근무자 없음</div>';
return '<div class="wp-assignees"><strong>배정된 근무자</strong><ul style="margin:4px 0 0 0;padding-left:16px;">' +
assigned.map(w => `<li>${escHtml(w.name)}</li>`).join('') + '</ul></div>';
}
function renderWorkplaceMarkers() {
const bounds = [];
for (const wp of store.workplaces) {
const marker = L.marker([wp.lat, wp.lng], { icon: WORKPLACE_ICON })
.addTo(map)
.bindPopup(`
<div class="popup-content">
<strong><i class="fas fa-building"></i> ${escHtml(wp.name)}</strong><br>
<small>${escHtml(wp.address)}</small>
${getWorkplaceAssigneesHtml(wp.id)}
</div>
`);
markers.workplaces[wp.id] = marker;
bounds.push([wp.lat, wp.lng]);
}
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
}
}
function renderWorkerMarkers() {
for (const w of store.workers) {
const isAssigned = store.assignments[w.id];
const icon = isAssigned ? WORKER_ASSIGNED_ICON : WORKER_ICON;
const marker = L.marker([w.lat, w.lng], { icon, draggable: false })
.addTo(map)
.bindPopup(`
<div class="popup-content">
<strong><i class="fas fa-user"></i> ${escHtml(w.name)}</strong><br>
<small>${escHtml(w.address)}</small><br>
<small>${escHtml(w.phone)}</small>
<div class="worker-status" id="worker-status-${w.id}">
${isAssigned ? `<span class="tag-assigned">배치됨 → ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
</div>
${isAssigned ? `<button class="btn-small btn-danger" onclick="unassignWorker('${w.id}')">배치 취소</button>` : ''}
</div>
`);
markers.workers[w.id] = marker;
}
}
function renderWorkerList() {
const list = document.getElementById('workerList');
list.innerHTML = '';
for (const w of store.workers) {
const isAssigned = store.assignments[w.id];
const card = document.createElement('div');
card.className = `worker-card ${isAssigned ? 'assigned' : ''}`;
card.draggable = true;
card.dataset.workerId = w.id;
card.innerHTML = `
<div class="worker-card-avatar ${isAssigned ? 'assigned' : ''}">
<i class="fas ${isAssigned ? 'fa-user-check' : 'fa-user'}"></i>
</div>
<div class="worker-card-info">
<div class="worker-card-name">${escHtml(w.name)}</div>
<div class="worker-card-detail">${escHtml(w.phone)}</div>
<div class="worker-card-address" title="${escHtml(w.address)}">${escHtml(w.address)}</div>
${w.hope ? `<div class="worker-card-hope"><i class="fas fa-star"></i> ${escHtml(w.hope)}</div>` : ''}
<div class="worker-card-assignment">
${isAssigned ? `<span class="tag-assigned"><i class="fas fa-check-circle"></i> ${escHtml(getWorkplaceName(isAssigned))}</span>` : '<span class="tag-unassigned">미배치</span>'}
</div>
</div>
`;
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', w.id);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', (e) => {
card.classList.remove('dragging');
});
list.appendChild(card);
}
}
function renderAssignmentLines() {
for (const [workerId, workplaceId] of Object.entries(store.assignments)) {
const wMarker = markers.workers[workerId];
const wpMarker = markers.workplaces[workplaceId];
if (!wMarker || !wpMarker) continue;
const line = L.polyline(
[wMarker.getLatLng(), wpMarker.getLatLng()],
{ color: '#10b981', weight: 2, dashArray: '6, 4', opacity: 0.6 }
).addTo(map);
assignmentLines.push(line);
}
}
function updateBadges() {
const assigned = Object.keys(store.assignments).length;
const total = store.workers.length;
document.getElementById('workerCount').textContent = `${assigned}/${total}`;
document.getElementById('statusBadge').textContent = assigned > 0 ? `배치 ${assigned}/${total}` : '';
document.getElementById('statusBadge').className = `status-badge ${assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;
}
function assignWorker(workerId, workplaceId) {
if (!store) return;
if (store.assignments[workerId] === workplaceId) return;
fetch('/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workerId, workplaceId }),
})
.then(r => r.json())
.then(data => {
store.assignments = data.assignments;
render();
showToast('배치가 저장되었습니다.', 'success');
})
.catch(() => showToast('배치 저장 실패', 'error'));
}
function unassignWorker(workerId) {
if (!store) return;
fetch('/unassign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workerId }),
})
.then(r => r.json())
.then(data => {
store.assignments = data.assignments;
render();
showToast('배치가 취소되었습니다.', 'info');
})
.catch(() => showToast('배치 취소 실패', 'error'));
}
function resetAssignments() {
if (!store || Object.keys(store.assignments).length === 0) return;
if (!confirm('모든 배치를 초기화하시겠습니까?')) return;
fetch('/reset', { method: 'POST' })
.then(r => r.json())
.then(data => {
store.assignments = data.assignments;
render();
showToast('모든 배치가 초기화되었습니다.', 'info');
})
.catch(() => showToast('초기화 실패', 'error'));
}
function exportExcel() {
if (!store || store.workers.length === 0) {
showToast('내보낼 데이터가 없습니다.', 'warning');
return;
}
window.location.href = '/export';
}
function getWorkplaceName(id) {
const wp = store.workplaces.find(p => p.id === id);
return wp ? wp.name : '(알 수 없음)';
}
function escHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = `toast ${type}`;
toast.classList.remove('hidden');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.add('hidden'), 3000);
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.