Initial commit: Map Flag Placer with Leaflet, Excel import/export, admin boundaries, and photo upload
This commit is contained in:
807
index.html
Normal file
807
index.html
Normal file
@@ -0,0 +1,807 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flag Placer</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color: #222; }
|
||||
#sidebar {
|
||||
width: 420px; min-width: 420px; background: #f4f5f7;
|
||||
display: flex; flex-direction: column; border-right: 2px solid #d0d4dc;
|
||||
z-index: 1000;
|
||||
}
|
||||
#map { flex: 1; }
|
||||
|
||||
.sb-header {
|
||||
padding: 14px 16px 12px; border-bottom: 1px solid #d0d4dc;
|
||||
background: #fff;
|
||||
}
|
||||
.sb-header h1 { font-size: 19px; }
|
||||
.sb-header h1 small { font-size: 12px; color: #666; font-weight: normal; }
|
||||
|
||||
.sb-body { flex: 1; overflow-y: auto; padding: 10px 14px; }
|
||||
.sb-footer {
|
||||
padding: 10px 14px; border-top: 1px solid #d0d4dc;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.upload-row {
|
||||
display: flex; gap: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.upload-row input[type="file"] { flex: 1; font-size: 12px; }
|
||||
.upload-row button, .sb-footer button {
|
||||
padding: 5px 12px; border: 1px solid #aaa; border-radius: 4px;
|
||||
background: #fff; cursor: pointer; font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
.upload-row button:hover, .sb-footer button:hover { background: #e9ecef; }
|
||||
.btn-primary {
|
||||
background: #2b6cb0; color: #fff; border-color: #2b6cb0;
|
||||
}
|
||||
.btn-primary:hover { background: #1a4f8b !important; }
|
||||
|
||||
.stats-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 8px; font-size: 13px;
|
||||
}
|
||||
.filter-btns { display: flex; gap: 4px; }
|
||||
.filter-btns button {
|
||||
padding: 3px 10px; border: 1px solid #ccc; border-radius: 10px;
|
||||
background: #fff; cursor: pointer; font-size: 11px;
|
||||
}
|
||||
.filter-btns button.active { background: #2b6cb0; color: #fff; border-color: #2b6cb0; }
|
||||
|
||||
/* ─── Boundary Section ─── */
|
||||
.boundary-section {
|
||||
background: #fff; border-radius: 8px; border: 1px solid #d0d4dc;
|
||||
margin-bottom: 8px; overflow: hidden;
|
||||
}
|
||||
.boundary-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 10px; cursor: pointer; user-select: none;
|
||||
font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.boundary-header .chevron {
|
||||
font-size: 11px; color: #a0aec0; transition: transform 0.2s;
|
||||
}
|
||||
.boundary-header .chevron.open { transform: rotate(180deg); }
|
||||
.boundary-body { padding: 0 10px 10px; display: none; }
|
||||
.boundary-body.open { display: block; }
|
||||
.boundary-row { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.boundary-row select {
|
||||
flex: 1; padding: 5px 6px; border: 1px solid #ccc; border-radius: 4px;
|
||||
font-size: 12px; background: #fff;
|
||||
}
|
||||
.boundary-row button {
|
||||
padding: 5px 10px; border: 1px solid #aaa; border-radius: 4px;
|
||||
background: #fff; cursor: pointer; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.boundary-row button:hover { background: #e9ecef; }
|
||||
.boundary-status {
|
||||
font-size: 11px; color: #718096; margin-top: 4px;
|
||||
}
|
||||
.boundary-status.loading { color: #2b6cb0; }
|
||||
.boundary-status.error { color: #e53e3e; }
|
||||
|
||||
/* ─── Flag list ─── */
|
||||
#flag-list { list-style: none; }
|
||||
|
||||
.flag-item {
|
||||
background: #fff; border-radius: 8px; border: 2px solid transparent;
|
||||
margin-bottom: 5px; overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.flag-item:hover { border-color: #b3d4fc; }
|
||||
.flag-item.selected { border-color: #2b6cb0; background: #ebf4ff; }
|
||||
|
||||
.flag-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; cursor: pointer; user-select: none;
|
||||
}
|
||||
.flag-header .idx {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #e2e8f0; color: #4a5568;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.flag-header .status-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.flag-header .status-dot.placed { background: #38a169; }
|
||||
.flag-header .status-dot.unplaced { background: #cbd5e0; }
|
||||
.flag-header .info { flex: 1; min-width: 0; font-size: 13px; }
|
||||
.flag-header .info .name { font-weight: 600; }
|
||||
.flag-header .info .detail {
|
||||
font-size: 11px; color: #718096; margin-top: 1px; line-height: 1.3;
|
||||
}
|
||||
.flag-header .chevron {
|
||||
flex-shrink: 0; font-size: 12px; color: #a0aec0;
|
||||
transition: transform 0.2s; width: 16px; text-align: center;
|
||||
}
|
||||
.flag-header .chevron.open { transform: rotate(180deg); }
|
||||
.flag-header .actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.flag-header .actions button {
|
||||
border: none; border-radius: 4px; cursor: pointer; font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.flag-header .actions .place-btn { background: #2b6cb0; color: #fff; }
|
||||
.flag-header .actions .place-btn:hover { background: #1a4f8b; }
|
||||
.flag-header .actions .remove-btn { background: #e53e3e; color: #fff; }
|
||||
.flag-header .actions .remove-btn:hover { background: #c53030; }
|
||||
|
||||
.flag-body {
|
||||
display: none; border-top: 1px solid #e2e8f0; padding: 10px 12px;
|
||||
font-size: 12px; color: #4a5568;
|
||||
}
|
||||
.flag-body.open { display: block; }
|
||||
.flag-body .detail-row { margin-bottom: 4px; }
|
||||
.flag-body .detail-row .label { color: #718096; }
|
||||
.flag-body .address-loading { color: #a0aec0; font-style: italic; }
|
||||
|
||||
.photo-section { margin-top: 8px; }
|
||||
.photo-section .photo-label { font-weight: 600; margin-bottom: 4px; color: #4a5568; }
|
||||
.photo-preview { margin-bottom: 6px; max-width: 100%; border-radius: 6px; overflow: hidden; }
|
||||
.photo-preview img {
|
||||
max-width: 100%; max-height: 140px; display: block; border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.photo-actions { display: flex; gap: 6px; align-items: center; }
|
||||
.photo-actions .photo-btn {
|
||||
padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px;
|
||||
background: #fff; cursor: pointer; font-size: 11px;
|
||||
}
|
||||
.photo-actions .photo-btn:hover { background: #e9ecef; }
|
||||
.photo-actions .photo-btn.danger { color: #e53e3e; border-color: #e53e3e; }
|
||||
.photo-actions .photo-btn.danger:hover { background: #fff5f5; }
|
||||
.photo-actions input[type="file"] { display: none; }
|
||||
|
||||
.num-marker {
|
||||
background: #2b6cb0; color: #fff; border-radius: 50%;
|
||||
width: 28px; height: 28px; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 12px; font-weight: 700;
|
||||
border: 3px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-text { font-size: 12px; color: #718096; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="sb-header">
|
||||
<h1>🚩 Flag Placer <small>click a flag → click map</small></h1>
|
||||
</div>
|
||||
|
||||
<div class="sb-body">
|
||||
<div class="upload-row">
|
||||
<input type="file" id="file-input" accept=".xlsx,.xls" />
|
||||
<button id="template-btn">Template</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span id="stats">0 / 0 placed</span>
|
||||
<div class="filter-btns">
|
||||
<button id="filter-all" class="active">All</button>
|
||||
<button id="filter-unplaced">To Place</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Administrative Boundaries -->
|
||||
<div class="boundary-section">
|
||||
<div class="boundary-header" id="boundary-toggle">
|
||||
<span>🗺️ 서울 행정구역 경계</span>
|
||||
<span class="chevron" id="boundary-chevron">▾</span>
|
||||
</div>
|
||||
<div class="boundary-body" id="boundary-body">
|
||||
<div class="boundary-row">
|
||||
<select id="gu-select">
|
||||
<option value="">— 구를 선택하세요 —</option>
|
||||
</select>
|
||||
<button id="clear-boundary">Clear</button>
|
||||
</div>
|
||||
<div class="boundary-status" id="boundary-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="flag-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="sb-footer">
|
||||
<button id="export-btn" class="btn-primary" disabled>Export to Excel</button>
|
||||
<div class="help-text">
|
||||
Click a flag header to expand (photo, details).<br>
|
||||
Click <strong>Place</strong> → then click the map.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<script>
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
let flags = [];
|
||||
let nextId = 1;
|
||||
let selectedFlagId = null;
|
||||
let filterMode = 'all';
|
||||
|
||||
const map = L.map('map').setView([37.5665, 126.978], 7);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function makeNumIcon(num) {
|
||||
return L.divIcon({
|
||||
html: `<div class="num-marker">${num}</div>`,
|
||||
className: '', iconSize: [28, 28], iconAnchor: [14, 14]
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredFlags() {
|
||||
if (filterMode === 'unplaced') return flags.filter(f => !f.placed);
|
||||
return flags;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"'})[c]);
|
||||
}
|
||||
|
||||
// ─── Reverse Geocode ─────────────────────────────────────
|
||||
let geocodeQueue = Promise.resolve();
|
||||
|
||||
async function reverseGeocode(lat, lng) {
|
||||
const resp = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`
|
||||
);
|
||||
if (!resp.ok) return 'Unknown';
|
||||
const data = await resp.json();
|
||||
return data.display_name || 'Unknown';
|
||||
}
|
||||
|
||||
function scheduleGeocode(flag) {
|
||||
geocodeQueue = geocodeQueue.then(async () => {
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
try {
|
||||
flag.address = await reverseGeocode(flag.lat, flag.lng);
|
||||
} catch { flag.address = 'Unknown'; }
|
||||
renderList();
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Photo handling ──────────────────────────────────────
|
||||
function handlePhotoUpload(flagId, file) {
|
||||
const flag = flags.find(f => f.id === flagId);
|
||||
if (!flag || !file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => { flag.photoData = e.target.result; renderList(); };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removePhoto(flagId) {
|
||||
const flag = flags.find(f => f.id === flagId);
|
||||
if (!flag) return;
|
||||
flag.photoData = null;
|
||||
renderList();
|
||||
}
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────
|
||||
function renderList() {
|
||||
const el = document.getElementById('flag-list');
|
||||
const filtered = getFilteredFlags();
|
||||
const placed = flags.filter(f => f.placed).length;
|
||||
|
||||
document.getElementById('stats').textContent = `${placed} / ${flags.length} placed`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
el.innerHTML = `<li style="text-align:center;padding:20px;color:#999;font-size:13px;">
|
||||
${flags.length === 0 ? 'Upload an Excel file to start.' : 'No unplaced flags left.'}
|
||||
</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '';
|
||||
filtered.forEach((flag) => {
|
||||
const globalIdx = flags.indexOf(flag) + 1;
|
||||
const isSelected = selectedFlagId === flag.id;
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'flag-item' + (isSelected ? ' selected' : '');
|
||||
li.dataset.id = flag.id;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flag-header';
|
||||
|
||||
let detailText = '';
|
||||
if (flag.placed) {
|
||||
detailText = `📍 ${flag.lat.toFixed(4)}, ${flag.lng.toFixed(4)}`;
|
||||
if (flag.address) detailText += ` · 🏷 ${flag.address.substring(0, 40)}${flag.address.length > 40 ? '…' : ''}`;
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
<span class="idx">${globalIdx}</span>
|
||||
<span class="status-dot ${flag.placed ? 'placed' : 'unplaced'}"></span>
|
||||
<div class="info">
|
||||
<div class="name">${esc(flag.name)}</div>
|
||||
<div class="detail">${flag.number ? '#' + esc(flag.number) : ''}${detailText ? ' · ' + detailText : ''}</div>
|
||||
</div>
|
||||
<span class="chevron ${flag.expanded ? 'open' : ''}">▾</span>
|
||||
<div class="actions">
|
||||
${!flag.placed
|
||||
? `<button class="place-btn">Place</button>`
|
||||
: `<button class="remove-btn">✕</button>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
header.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.actions')) return;
|
||||
flag.expanded = !flag.expanded;
|
||||
renderList();
|
||||
});
|
||||
|
||||
li.appendChild(header);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'flag-body' + (flag.expanded ? ' open' : '');
|
||||
|
||||
let bodyHTML = '';
|
||||
if (flag.placed) {
|
||||
bodyHTML += `<div class="detail-row">
|
||||
<span class="label">Coordinates:</span>
|
||||
<span>${flag.lat.toFixed(6)}, ${flag.lng.toFixed(6)}</span>
|
||||
</div>`;
|
||||
bodyHTML += `<div class="detail-row">
|
||||
<span class="label">Address:</span>
|
||||
<span>${flag.address ? esc(flag.address) : '<span class="address-loading">Loading…</span>'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
bodyHTML += `<div class="photo-section">
|
||||
<div class="photo-label">📷 Photo</div>`;
|
||||
if (flag.photoData) {
|
||||
bodyHTML += `<div class="photo-preview"><img src="${flag.photoData}" alt="Photo" /></div>`;
|
||||
}
|
||||
bodyHTML += `<div class="photo-actions">
|
||||
<label class="photo-btn">${flag.photoData ? 'Change Photo' : 'Upload Photo'}
|
||||
<input type="file" accept="image/*" class="photo-input" />
|
||||
</label>`;
|
||||
if (flag.photoData) {
|
||||
bodyHTML += `<button class="photo-btn danger" data-action="remove-photo">Remove</button>`;
|
||||
}
|
||||
bodyHTML += `</div></div>`;
|
||||
|
||||
body.innerHTML = bodyHTML;
|
||||
|
||||
fileInput = body.querySelector('.photo-input');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => { handlePhotoUpload(flag.id, e.target.files[0]); });
|
||||
}
|
||||
removeBtn = body.querySelector('[data-action="remove-photo"]');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => removePhoto(flag.id));
|
||||
}
|
||||
|
||||
li.appendChild(body);
|
||||
|
||||
header.querySelector('.place-btn')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedFlagId = flag.id;
|
||||
renderList();
|
||||
});
|
||||
|
||||
header.querySelector('.remove-btn')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeFlag(flag.id);
|
||||
});
|
||||
|
||||
el.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
let fileInput, removeBtn;
|
||||
|
||||
// ─── Flag Operations ─────────────────────────────────────
|
||||
function removeFlag(id) {
|
||||
const flag = flags.find(f => f.id === id);
|
||||
if (!flag || !flag.placed) return;
|
||||
if (flag.marker) map.removeLayer(flag.marker);
|
||||
flag.placed = false;
|
||||
flag.lat = null;
|
||||
flag.lng = null;
|
||||
flag.address = null;
|
||||
flag.marker = null;
|
||||
if (selectedFlagId === id) selectedFlagId = null;
|
||||
renderList();
|
||||
}
|
||||
|
||||
function addFlag(name, number) {
|
||||
const flag = {
|
||||
id: nextId++, name, number,
|
||||
placed: false, lat: null, lng: null, address: null,
|
||||
marker: null, expanded: false, photoData: null
|
||||
};
|
||||
flags.push(flag);
|
||||
return flag;
|
||||
}
|
||||
|
||||
// ─── Map Click ────────────────────────────────────────────
|
||||
map.on('click', (e) => {
|
||||
if (!selectedFlagId) { alert('Select a flag from the list first.'); return; }
|
||||
const flag = flags.find(f => f.id === selectedFlagId);
|
||||
if (!flag || flag.placed) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
const globalIdx = flags.indexOf(flag) + 1;
|
||||
const marker = L.marker([lat, lng], { icon: makeNumIcon(globalIdx), draggable: true }).addTo(map);
|
||||
marker.bindTooltip(`${flag.name}${flag.number ? ' (' + flag.number + ')' : ''}`, { direction: 'top' });
|
||||
|
||||
marker.on('dragend', () => {
|
||||
const pos = marker.getLatLng();
|
||||
flag.lat = pos.lat;
|
||||
flag.lng = pos.lng;
|
||||
scheduleGeocode(flag);
|
||||
renderList();
|
||||
});
|
||||
marker.on('contextmenu', () => { removeFlag(flag.id); });
|
||||
|
||||
flag.placed = true;
|
||||
flag.lat = lat;
|
||||
flag.lng = lng;
|
||||
flag.marker = marker;
|
||||
selectedFlagId = null;
|
||||
|
||||
renderList();
|
||||
scheduleGeocode(flag);
|
||||
});
|
||||
|
||||
// ─── Filter ───────────────────────────────────────────────
|
||||
document.getElementById('filter-all').addEventListener('click', () => {
|
||||
filterMode = 'all';
|
||||
document.querySelectorAll('.filter-btns button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('filter-all').classList.add('active');
|
||||
renderList();
|
||||
});
|
||||
document.getElementById('filter-unplaced').addEventListener('click', () => {
|
||||
filterMode = 'unplaced';
|
||||
document.querySelectorAll('.filter-btns button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('filter-unplaced').classList.add('active');
|
||||
renderList();
|
||||
});
|
||||
|
||||
// ─── Excel Import ─────────────────────────────────────────
|
||||
document.getElementById('file-input').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
for (const f of flags) { if (f.marker) map.removeLayer(f.marker); }
|
||||
flags = [];
|
||||
nextId = 1;
|
||||
selectedFlagId = null;
|
||||
|
||||
const data = await file.arrayBuffer();
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.Name || row.name || row.이름 || row['Flag Name'] || Object.values(row)[0] || '';
|
||||
const number = row.Number || row.number || row.숫자 || row['Flag Number'] || Object.values(row)[1] || '';
|
||||
const nameStr = String(name).trim();
|
||||
if (!nameStr) continue;
|
||||
|
||||
const flag = addFlag(nameStr, String(number).trim());
|
||||
|
||||
let lat = parseFloat(row.Latitude || row.latitude || row.위도);
|
||||
let lng = parseFloat(row.Longitude || row.longitude || row.경도);
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
const globalIdx = flags.indexOf(flag) + 1;
|
||||
const marker = L.marker([lat, lng], { icon: makeNumIcon(globalIdx), draggable: true }).addTo(map);
|
||||
marker.bindTooltip(`${flag.name}${flag.number ? ' (' + flag.number + ')' : ''}`, { direction: 'top' });
|
||||
marker.on('dragend', () => {
|
||||
const pos = marker.getLatLng();
|
||||
flag.lat = pos.lat;
|
||||
flag.lng = pos.lng;
|
||||
scheduleGeocode(flag);
|
||||
renderList();
|
||||
});
|
||||
marker.on('contextmenu', () => { removeFlag(flag.id); });
|
||||
flag.placed = true;
|
||||
flag.lat = lat;
|
||||
flag.lng = lng;
|
||||
flag.marker = marker;
|
||||
|
||||
if (row.Address || row.address || row.주소) {
|
||||
flag.address = String(row.Address || row.address || row.주소);
|
||||
} else {
|
||||
scheduleGeocode(flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.length > 0) {
|
||||
document.getElementById('export-btn').disabled = false;
|
||||
map.invalidateSize();
|
||||
}
|
||||
|
||||
renderList();
|
||||
|
||||
const placedFlags = flags.filter(f => f.placed);
|
||||
if (placedFlags.length > 0) {
|
||||
const group = L.featureGroup(placedFlags.map(f => f.marker));
|
||||
map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Download Template ────────────────────────────────────
|
||||
document.getElementById('template-btn').addEventListener('click', () => {
|
||||
const wb = XLSX.utils.book_new();
|
||||
const data = [
|
||||
{ Name: 'Flag 1', Number: 'A001' },
|
||||
{ Name: 'Flag 2', Number: 'A002' },
|
||||
{ Name: 'Flag 3', Number: 'A003' },
|
||||
];
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Flags');
|
||||
XLSX.writeFile(wb, 'flag_template.xlsx');
|
||||
});
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────
|
||||
document.getElementById('export-btn').addEventListener('click', () => {
|
||||
const rows = flags.map(f => ({
|
||||
Name: f.name,
|
||||
Number: f.number,
|
||||
Status: f.placed ? 'Placed' : 'Unplaced',
|
||||
Latitude: f.lat ?? '',
|
||||
Longitude: f.lng ?? '',
|
||||
Address: f.address ?? '',
|
||||
}));
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
ws['!cols'] = [{ wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 12 }, { wch: 12 }, { wch: 60 }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Flags');
|
||||
XLSX.writeFile(wb, 'flag_export.xlsx');
|
||||
});
|
||||
|
||||
// ─── Keyboard ─────────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { selectedFlagId = null; renderList(); }
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ─── Administrative Boundaries (서울 구/동 경계) ─────────
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const SEOUL_GUS = [
|
||||
'종로구','중구','용산구','성동구','광진구','동대문구','중랑구',
|
||||
'성북구','강북구','도봉구','노원구','은평구','서대문구','마포구',
|
||||
'양천구','강서구','구로구','금천구','영등포구','동작구','관악구',
|
||||
'서초구','강남구','송파구','강동구'
|
||||
];
|
||||
|
||||
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
let boundaryLayer = null; // single L.geoJSON layer for all boundary geo
|
||||
let currentGu = null;
|
||||
let boundaryLoading = false;
|
||||
|
||||
// Populate the select
|
||||
const guSelect = document.getElementById('gu-select');
|
||||
SEOUL_GUS.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g; opt.textContent = g;
|
||||
guSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
function setBoundaryStatus(msg, type) {
|
||||
const el = document.getElementById('boundary-status');
|
||||
el.textContent = msg;
|
||||
el.className = 'boundary-status' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
function clearBoundaries() {
|
||||
if (boundaryLayer) { map.removeLayer(boundaryLayer); boundaryLayer = null; }
|
||||
currentGu = null;
|
||||
guSelect.value = '';
|
||||
setBoundaryStatus('');
|
||||
}
|
||||
|
||||
function overpassQuery(query) {
|
||||
return fetch(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'data=' + encodeURIComponent(query)
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
// Convert Overpass relation (with out geom) → GeoJSON polygon(s)
|
||||
function relationToGeoJSON(el) {
|
||||
if (el.type !== 'relation' || !el.members) return null;
|
||||
|
||||
const outerRings = [];
|
||||
const innerRings = [];
|
||||
|
||||
for (const m of el.members) {
|
||||
if (m.role === 'outer' && m.geometry) {
|
||||
const coords = m.geometry.map(g => [g.lon, g.lat]);
|
||||
if (coords.length >= 3 && isClosed(coords)) {
|
||||
outerRings.push(coords);
|
||||
}
|
||||
}
|
||||
if (m.role === 'inner' && m.geometry) {
|
||||
const coords = m.geometry.map(g => [g.lon, g.lat]);
|
||||
if (coords.length >= 3 && isClosed(coords)) {
|
||||
innerRings.push(coords);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outerRings.length === 0) return null;
|
||||
|
||||
// Build polygons: each outer + its following inner rings as holes
|
||||
const polygons = [];
|
||||
for (const outer of outerRings) {
|
||||
const poly = [outer];
|
||||
// Simple heuristic: assign inners that likely belong to this outer
|
||||
// In practice each relation usually has 1 outer and N inners
|
||||
if (innerRings.length > 0 && outerRings.length === 1) {
|
||||
poly.push(...innerRings);
|
||||
}
|
||||
polygons.push(poly);
|
||||
}
|
||||
|
||||
if (polygons.length === 1) {
|
||||
return { type: 'Polygon', coordinates: polygons[0] };
|
||||
}
|
||||
return { type: 'MultiPolygon', coordinates: polygons };
|
||||
}
|
||||
|
||||
function isClosed(coords) {
|
||||
const first = coords[0];
|
||||
const last = coords[coords.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1];
|
||||
}
|
||||
|
||||
// Collect all members' geometry into rings even from different elements
|
||||
function collectRings(elements, role) {
|
||||
const rings = [];
|
||||
for (const el of elements) {
|
||||
if (el.type === 'relation') {
|
||||
for (const m of el.members) {
|
||||
if (m.role === role && m.geometry) {
|
||||
const coords = m.geometry.map(g => [g.lon, g.lat]);
|
||||
if (coords.length >= 3 && isClosed(coords)) rings.push(coords);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some responses return ways directly
|
||||
if (el.type === 'way' && el.geometry) {
|
||||
const coords = el.geometry.map(g => [g.lon, g.lat]);
|
||||
if (coords.length >= 3 && isClosed(coords)) rings.push(coords);
|
||||
}
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
async function fetchGuBoundary(guName) {
|
||||
const q = `[out:json][timeout:30];
|
||||
area["name"="서울특별시"]["boundary"="administrative"]->.seoul;
|
||||
rel(area.seoul)["boundary"="administrative"]["name"="${guName}"];
|
||||
out geom;`;
|
||||
const data = await overpassQuery(q);
|
||||
if (!data.elements || data.elements.length === 0) return null;
|
||||
return data.elements[0];
|
||||
}
|
||||
|
||||
async function fetchDongBoundaries(guName) {
|
||||
const q = `[out:json][timeout:30];
|
||||
area["name"="서울특별시"]["boundary"="administrative"]->.seoul;
|
||||
area["boundary"="administrative"]["name"="${guName}"]->.gu;
|
||||
rel(area.seoul)(area.gu)["boundary"="administrative"]["admin_level"="9"];
|
||||
out geom;`;
|
||||
const data = await overpassQuery(q);
|
||||
return data.elements || [];
|
||||
}
|
||||
|
||||
async function showBoundaries(guName) {
|
||||
if (boundaryLoading) return;
|
||||
boundaryLoading = true;
|
||||
setBoundaryStatus('Loading boundaries…', 'loading');
|
||||
|
||||
try {
|
||||
clearBoundaries();
|
||||
|
||||
const [guElem, dongElems] = await Promise.all([
|
||||
fetchGuBoundary(guName),
|
||||
fetchDongBoundaries(guName)
|
||||
]);
|
||||
|
||||
const features = [];
|
||||
|
||||
// 구 boundary
|
||||
if (guElem) {
|
||||
const guGeo = relationToGeoJSON(guElem);
|
||||
if (guGeo) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { type: 'gu', name: guName },
|
||||
geometry: guGeo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 동 boundaries
|
||||
for (const el of dongElems) {
|
||||
const dongGeo = relationToGeoJSON(el);
|
||||
if (dongGeo) {
|
||||
const dongName = el.tags?.name || el.tags?.admin_name || '';
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { type: 'dong', name: dongName },
|
||||
geometry: dongGeo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (features.length === 0) {
|
||||
setBoundaryStatus('No boundary data found.', 'error');
|
||||
boundaryLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
boundaryLayer = L.geoJSON({ type: 'FeatureCollection', features }, {
|
||||
style: (feature) => {
|
||||
if (feature.properties.type === 'gu') {
|
||||
return { color: '#2b6cb0', weight: 3, fill: false, opacity: 0.9 };
|
||||
}
|
||||
return {
|
||||
color: '#4299e1', weight: 1.5, fill: true,
|
||||
fillColor: '#4299e1', fillOpacity: 0.06, opacity: 0.7
|
||||
};
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
if (feature.properties.name) {
|
||||
const label = feature.properties.type === 'gu'
|
||||
? `🗺️ ${feature.properties.name}`
|
||||
: `📍 ${feature.properties.name}`;
|
||||
layer.bindTooltip(label, { direction: 'center', sticky: true });
|
||||
}
|
||||
}
|
||||
}).addTo(map);
|
||||
|
||||
currentGu = guName;
|
||||
setBoundaryStatus(`${dongElems.length} 동 경계 로딩됨 (${guName})`);
|
||||
map.fitBounds(boundaryLayer.getBounds().pad(0.05));
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setBoundaryStatus('Failed to load boundaries.', 'error');
|
||||
}
|
||||
|
||||
boundaryLoading = false;
|
||||
}
|
||||
|
||||
// ─── Boundary UI Events ───────────────────────────────────
|
||||
guSelect.addEventListener('change', () => {
|
||||
const val = guSelect.value;
|
||||
if (!val) { clearBoundaries(); return; }
|
||||
showBoundaries(val);
|
||||
});
|
||||
|
||||
document.getElementById('clear-boundary').addEventListener('click', clearBoundaries);
|
||||
|
||||
// Collapsible section
|
||||
document.getElementById('boundary-toggle').addEventListener('click', () => {
|
||||
const body = document.getElementById('boundary-body');
|
||||
const chevron = document.getElementById('boundary-chevron');
|
||||
const isOpen = body.classList.toggle('open');
|
||||
chevron.classList.toggle('open', isOpen);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user