Files
MapAssignFlag/index.html
user01 b87d6302d3 Make name tooltip permanent and 1.2x larger
- Add permanent: true to tooltip (always visible speech bubble)
- Add CSS class flag-marker-tooltip with 1.2em font size
- Use white-space: nowrap to prevent word wrapping
2026-05-15 18:56:27 +09:00

954 lines
40 KiB
HTML

<!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: 12px 14px 10px; border-bottom: 1px solid #d0d4dc;
background: #fff;
}
.sb-header h1 { font-size: 18px; display: flex; align-items: center; gap: 6px; }
.sb-header h1 small { font-size: 11px; color: #666; font-weight: normal; }
.api-row {
display: flex; gap: 4px; margin-top: 6px; align-items: center;
}
.api-row input {
flex: 1; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px;
font-size: 11px; font-family: monospace;
}
.api-row button {
padding: 4px 8px; border: 1px solid #aaa; border-radius: 4px;
background: #fff; cursor: pointer; font-size: 11px; white-space: nowrap;
}
.api-row button:hover { background: #e9ecef; }
.api-row .api-status {
font-size: 10px; padding: 2px 6px; border-radius: 3px;
cursor: default;
}
.api-status.ok { background: #c6f6d5; color: #22543d; }
.api-status.warn { background: #fefcbf; color: #744210; }
.api-status.off { background: #fed7d7; color: #822727; }
.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 {
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; }
.boundary-status.warn { color: #d69e2e; }
#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; }
.flag-marker-tooltip { font-size: 1.2em !important; padding: 6px 12px !important; white-space: nowrap; }
</style>
</head>
<body>
<div id="sidebar">
<div class="sb-header">
<h1>🚩 Flag Placer <small>VWORLD + fallback</small></h1>
<div class="api-row">
<input type="text" id="api-key-input" placeholder="VWORLD API Key" />
<button id="api-key-btn">Apply</button>
<span class="api-status" id="api-status" title="Current service status">---</span>
</div>
</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>
<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>
// ═══════════════════════════════════════════════════════════
// ─── VWORLD API Key ──────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const VWORLD_KEY = 'vworld_api_key';
const DEFAULT_KEY = 'E1FD0345-8021-3C9C-A397-CE0D88736D78';
function getKey() { return localStorage.getItem(VWORLD_KEY) || ''; }
function saveKey(k) { localStorage.setItem(VWORLD_KEY, k); }
if (!localStorage.getItem(VWORLD_KEY)) saveKey(DEFAULT_KEY);
// ═══════════════════════════════════════════════════════════
// ─── Fetch with timeout ──────────────────────────────────
// ═══════════════════════════════════════════════════════════
async function fetchWithTimeout(url, timeout = 10000, options = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
try {
const resp = await fetch(url, { ...options, signal: ctrl.signal });
return resp;
} finally {
clearTimeout(timer);
}
}
function jsonpFetch(url, callbackParam = 'callback', timeout = 15000) {
return new Promise((resolve, reject) => {
const cbName = '_vw' + Date.now() + Math.random().toString(36).slice(2, 6);
const sep = url.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = url + sep + callbackParam + '=' + cbName;
const timer = setTimeout(() => { cleanup(); reject(new Error('JSONP timeout')); }, timeout);
window[cbName] = (data) => { cleanup(); resolve(data); };
function cleanup() {
clearTimeout(timer);
delete window[cbName];
if (script.parentNode) script.parentNode.removeChild(script);
}
script.onerror = () => { cleanup(); reject(new Error('JSONP load error')); };
document.head.appendChild(script);
});
}
// ═══════════════════════════════════════════════════════════
// ─── Map (VWORLD → OSM fallback) ─────────────────────────
// ═══════════════════════════════════════════════════════════
const map = L.map('map').setView([37.5665, 126.978], 7);
let tileLayer = null;
let tileService = 'none';
function updateApiStatus(className, text) {
const el = document.getElementById('api-status');
el.className = 'api-status ' + className;
el.textContent = text;
}
let tileFallbackDone = false;
function setupTileLayer(key) {
if (tileLayer) map.removeLayer(tileLayer);
tileFallbackDone = false;
if (key) {
tileLayer = L.tileLayer(
`https://api.vworld.kr/req/wmts/1.0.0/${key}/Base/{z}/{y}/{x}.png`,
{ maxZoom: 20, attribution: 'VWORLD' }
);
tileService = 'vworld';
let errorCount = 0;
tileLayer.on('tileerror', () => {
if (tileFallbackDone) return;
errorCount++;
if (errorCount >= 3) {
tileFallbackDone = true;
console.warn('VWORLD tiles failed, falling back to OSM');
map.removeLayer(tileLayer);
tileLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom: 19, attribution: '&copy; OpenStreetMap contributors' }
).addTo(map);
tileService = 'osm';
updateApiStatus('warn', 'OSM (VWORLD 타일 실패)');
}
});
} else {
tileLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom: 19, attribution: '&copy; OpenStreetMap contributors' }
);
tileService = 'osm';
}
tileLayer.addTo(map);
updateApiStatus(
key && tileService === 'vworld' ? 'ok' : 'off',
tileService === 'vworld' ? 'VWORLD' : 'OSM'
);
}
// ═══════════════════════════════════════════════════════════
// ─── Reverse Geocode (VWORLD → Nominatim fallback) ──────
// ═══════════════════════════════════════════════════════════
let geocodeService = 'vworld';
const geocodeQueue = [];
let geocodeBusy = false;
let lastGeocodeTs = 0;
async function processGeocodeQueue() {
if (geocodeBusy || geocodeQueue.length === 0) return;
geocodeBusy = true;
while (geocodeQueue.length > 0) {
const flag = geocodeQueue.shift();
const minGap = geocodeService === 'vworld' ? 200 : 1100;
const elapsed = Date.now() - lastGeocodeTs;
if (elapsed < minGap) await new Promise(r => setTimeout(r, minGap - elapsed));
try {
const key = getKey();
if (geocodeService === 'vworld' && key) {
const url = 'https://api.vworld.kr/req/address?' +
'service=address&request=getaddress&version=2.0' +
'&crs=epsg:4326&point=' + flag.lng + ',' + flag.lat +
'&format=json&type=both&key=' + key;
const data = await jsonpFetch(url, 'callback', 8000);
if (data.response?.status === 'OK' && data.response.result?.length > 0) {
flag.address = data.response.result[0].text || 'Unknown';
} else {
throw new Error('VWORLD geocode error');
}
} else {
throw new Error('use fallback');
}
} catch {
if (geocodeService === 'vworld') {
console.warn('VWORLD geocode failed → Nominatim');
geocodeService = 'nominatim';
updateApiStatus('warn', 'VWORLD, 지오코딩:Nominatim');
}
// Nominatim fallback (1.1s gap already enforced)
try {
const resp = await fetchWithTimeout(
`https://nominatim.openstreetmap.org/reverse?lat=${flag.lat}&lon=${flag.lng}&format=json`, 8000);
if (resp.ok) {
const data = await resp.json();
flag.address = data.display_name || 'Unknown';
}
} catch {
flag.address = 'Unknown';
}
}
lastGeocodeTs = Date.now();
renderList();
}
geocodeBusy = false;
}
function scheduleGeocode(flag) {
geocodeQueue.push(flag);
processGeocodeQueue();
}
// ═══════════════════════════════════════════════════════════
// ─── State ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
let flags = [];
let nextId = 1;
let selectedFlagId = null;
let filterMode = 'all';
// ═══════════════════════════════════════════════════════════
// ─── API Key UI ──────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const apiKeyInput = document.getElementById('api-key-input');
apiKeyInput.value = getKey();
setupTileLayer(getKey());
document.getElementById('api-key-btn').addEventListener('click', () => {
const key = apiKeyInput.value.trim();
saveKey(key);
geocodeService = 'vworld';
boundaryService = 'vworld';
setupTileLayer(key);
if (currentGu) showBoundaries(currentGu);
else clearBoundaries();
});
// ═══════════════════════════════════════════════════════════
// ─── Helpers ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
function makeNumIcon(num) {
return L.divIcon({
html: `<div class="num-marker">${num}</div>`,
className: '', iconSize: [28, 28], iconAnchor: [14, 14]
});
}
function getFilteredFlags() {
return filterMode === 'unplaced' ? flags.filter(f => !f.placed) : flags;
}
function esc(s) {
return String(s).replace(/[&<>"]/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]);
}
// ═══════════════════════════════════════════════════════════
// ─── Photo ─────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
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 displayIdx = flag.number || 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 += ' · 🏷 ' + esc(flag.address.substring(0, 40)) + (flag.address.length > 40 ? '…' : '');
}
}
header.innerHTML = `
<span class="idx">${displayIdx}</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 bHTML = '';
if (flag.placed) {
bHTML += `<div class="detail-row"><span class="label">Coordinates:</span>
<span>${flag.lat.toFixed(6)}, ${flag.lng.toFixed(6)}</span></div>`;
bHTML += `<div class="detail-row"><span class="label">Address:</span>
<span>${flag.address ? esc(flag.address) : '<span class="address-loading">Loading…</span>'}</span></div>`;
}
bHTML += `<div class="photo-section"><div class="photo-label">📷 Photo</div>`;
if (flag.photoData) bHTML += `<div class="photo-preview"><img src="${flag.photoData}" alt="Photo" /></div>`;
bHTML += `<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) bHTML += `<button class="photo-btn danger" data-action="remove-photo">Remove</button>`;
bHTML += `</div></div>`;
body.innerHTML = bHTML;
body.querySelector('.photo-input')?.addEventListener('change',
(e) => handlePhotoUpload(flag.id, e.target.files[0]));
body.querySelector('[data-action="remove-photo"]')?.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);
});
}
// ═══════════════════════════════════════════════════════════
// ─── Flag Ops ───────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
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 displayNum = flag.number || flags.indexOf(flag) + 1;
const marker = L.marker([lat, lng], { icon: makeNumIcon(displayNum), draggable: true }).addTo(map);
if (flag.name) marker.bindTooltip(esc(flag.name), { permanent: true, direction: 'right', offset: [10, 0], className: 'flag-marker-tooltip' });
marker.on('dragend', () => {
const p = marker.getLatLng(); flag.lat = p.lat; flag.lng = p.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);
});
// ═══════════════════════════════════════════════════════════
// ─── Filters ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
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 rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { 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] || '';
if (!String(name).trim()) continue;
const flag = addFlag(String(name).trim(), 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 displayNum = flag.number || flags.indexOf(flag) + 1;
const marker = L.marker([lat, lng], { icon: makeNumIcon(displayNum), draggable: true }).addTo(map);
if (flag.name) marker.bindTooltip(esc(flag.name), { permanent: true, direction: 'right', offset: [10, 0], className: 'flag-marker-tooltip' });
marker.on('dragend', () => {
const p = marker.getLatLng(); flag.lat = p.lat; flag.lng = p.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 pf = flags.filter(f => f.placed);
if (pf.length > 0) map.fitBounds(L.featureGroup(pf.map(f => f.marker)).getBounds().pad(0.1));
});
// ═══════════════════════════════════════════════════════════
// ─── Template & Export ─────────────────────────────────
// ═══════════════════════════════════════════════════════════
document.getElementById('template-btn').addEventListener('click', () => {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet([
{ Number: 'A001', Name: 'Flag 1' },
{ Number: 'A002', Name: 'Flag 2' },
{ Number: 'A003', Name: 'Flag 3' },
]), 'Flags');
XLSX.writeFile(wb, 'flag_template.xlsx');
});
document.getElementById('export-btn').addEventListener('click', () => {
const rows = flags.map(f => ({
Number: f.number, Name: f.name,
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: 12 }, { wch: 20 }, { 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(); }
});
// ═══════════════════════════════════════════════════════════
// ─── Admin Boundaries (VWORLD → Overpass fallback) ─────
// ═══════════════════════════════════════════════════════════
const SEOUL_GUS = [
'종로구','중구','용산구','성동구','광진구','동대문구','중랑구',
'성북구','강북구','도봉구','노원구','은평구','서대문구','마포구',
'양천구','강서구','구로구','금천구','영등포구','동작구','관악구',
'서초구','강남구','송파구','강동구'
];
const guSelect = document.getElementById('gu-select');
SEOUL_GUS.forEach(g => {
const opt = document.createElement('option');
opt.value = g; opt.textContent = g;
guSelect.appendChild(opt);
});
let boundaryLayer = null;
let currentGu = null;
let boundaryLoading = false;
let boundaryService = 'vworld';
function setBoundaryStatus(msg, type) {
document.getElementById('boundary-status').textContent = msg;
document.getElementById('boundary-status').className = 'boundary-status' + (type ? ' ' + type : '');
}
function clearBoundaries() {
if (boundaryLayer) { map.removeLayer(boundaryLayer); boundaryLayer = null; }
currentGu = null; guSelect.value = '';
setBoundaryStatus('');
}
// ── Overpass fallback ──
async function overpassQuery(query) {
const resp = await fetchWithTimeout('https://overpass-api.de/api/interpreter', 30000, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'data=' + encodeURIComponent(query)
});
if (!resp.ok) throw new Error('Overpass API error: ' + resp.status);
const text = await resp.text();
try { return JSON.parse(text); } catch { throw new Error('Overpass: invalid JSON response'); }
}
function ptDist(a, b) {
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
}
function assembleRings(segments) {
if (segments.length === 0) return [];
const EPS = 0.001;
const copy = segments.map(s => [...s]);
const rings = [];
while (copy.length > 0) {
let ring = copy.shift();
let changed;
do {
changed = false;
for (let i = copy.length - 1; i >= 0; i--) {
const seg = copy[i];
const rs = ring[0], re = ring[ring.length - 1];
const ss = seg[0], se = seg[seg.length - 1];
if (ptDist(re, ss) < EPS) {
ring.push(...seg.slice(1)); copy.splice(i, 1); changed = true;
} else if (ptDist(re, se) < EPS) {
seg.reverse(); ring.push(...seg.slice(1)); copy.splice(i, 1); changed = true;
} else if (ptDist(rs, se) < EPS) {
ring.unshift(...seg.slice(0, -1)); copy.splice(i, 1); changed = true;
} else if (ptDist(rs, ss) < EPS) {
seg.reverse(); ring.unshift(...seg.slice(0, -1)); copy.splice(i, 1); changed = true;
}
}
} while (changed);
if (ring.length >= 3) rings.push(ring);
}
const areas = rings.map(r => {
let a = 0;
for (let i = 0; i < r.length; i++) {
const j = (i + 1) % r.length;
a += r[i][0] * r[j][1] - r[j][0] * r[i][1];
}
return Math.abs(a) / 2;
});
const maxArea = Math.max(...areas);
return rings.filter((_, i) => areas[i] > maxArea * 0.01);
}
function relationToGeoJSON(el) {
if (!el?.members) return null;
const outers = [], inners = [];
for (const m of el.members) {
if ((m.role === 'outer' || m.role === 'inner') && m.geometry) {
const coords = m.geometry.map(g => [g.lon, g.lat]);
if (coords.length >= 3) (m.role === 'outer' ? outers : inners).push(coords);
}
}
if (outers.length === 0) return null;
const outerRings = assembleRings(outers);
if (outerRings.length === 0) return null;
const innerRings = assembleRings(inners);
const polygons = outerRings.map(r => [r]);
if (innerRings.length > 0 && outerRings.length === 1) polygons[0].push(...innerRings);
return polygons.length === 1
? { type: 'Polygon', coordinates: polygons[0] }
: { type: 'MultiPolygon', coordinates: polygons };
}
async function showBoundariesOverpass(guName) {
const [guData, dongData] = await Promise.all([
overpassQuery(`[out:json][timeout:30];
area["name"="서울특별시"]["boundary"="administrative"]->.seoul;
rel(area.seoul)["boundary"="administrative"]["name"="${guName}"]; out geom;`),
overpassQuery(`[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 features = [];
if (guData?.elements?.[0]) {
const g = relationToGeoJSON(guData.elements[0]);
if (g) features.push({ type: 'Feature', properties: { type: 'gu', name: guName }, geometry: g });
}
for (const el of dongData?.elements || []) {
const g = relationToGeoJSON(el);
if (g) features.push({ type: 'Feature', properties: { type: 'dong', name: el.tags?.name || '' }, geometry: g });
}
return features;
}
// ── VWORLD ──
async function vworldDataQuery(params) {
const key = getKey();
if (!key) throw new Error('no key');
const q = new URLSearchParams({
key, service: 'data', request: 'GetFeature', version: '2.0',
format: 'json', crs: 'epsg:4326', geometry: 'true', size: '1000',
...params
});
return jsonpFetch('https://api.vworld.kr/req/data?' + q.toString(), 'callback', 15000);
}
function extractFeatures(data) {
try {
if (data?.response?.result?.featureCollection?.features) return data.response.result.featureCollection.features;
if (data?.result?.featureCollection?.features) return data.result.featureCollection.features;
if (data?.response?.result?.features) return data.response.result.features;
if (data?.result?.features) return data.result.features;
if (data?.features) return data.features;
} catch {}
return [];
}
async function showBoundariesVworld(guName) {
const filter = 'full_nm:like:서울특별시 ' + guName;
const [guData, dongData] = await Promise.all([
vworldDataQuery({ data: 'LT_C_ADSIGG_INFO', attrFilter: filter }),
vworldDataQuery({ data: 'LT_C_ADEMD_INFO', attrFilter: filter })
]);
const guFeatures = extractFeatures(guData);
const dongFeatures = extractFeatures(dongData);
const features = [];
if (guFeatures[0]) {
features.push({
type: 'Feature', properties: { type: 'gu', name: guName, ...(guFeatures[0].properties || {}) },
geometry: guFeatures[0].geometry
});
}
for (const f of dongFeatures) {
features.push({
type: 'Feature',
properties: { type: 'dong', name: f.properties?.emd_kor_nm || f.properties?.full_nm || '' },
geometry: f.geometry
});
}
return features;
}
// ── Unified boundary loader with fallback ──
async function showBoundaries(guName) {
if (boundaryLoading) return;
boundaryLoading = true;
setBoundaryStatus('Loading boundaries…', 'loading');
try {
clearBoundaries();
let features = [];
if (getKey() && boundaryService === 'vworld') {
try {
features = await showBoundariesVworld(guName);
if (features.length === 0) throw new Error('no features');
} catch (e) {
console.warn('VWORLD boundaries failed → Overpass:', e);
boundaryService = 'overpass';
updateApiStatus('warn', 'VWORLD, 경계:Overpass');
}
}
if (features.length === 0) {
features = await showBoundariesOverpass(guName);
}
if (features.length === 0) {
setBoundaryStatus('No boundary data found.', 'error');
boundaryLoading = false;
return;
}
boundaryLayer = L.geoJSON({ type: 'FeatureCollection', features }, {
style: (f) => f.properties.type === 'gu'
? { color: '#2b6cb0', weight: 3, fill: false, opacity: 0.9 }
: { color: '#4299e1', weight: 1.5, fill: true, fillColor: '#4299e1', fillOpacity: 0.06, opacity: 0.7 },
onEachFeature: (f, layer) => {
const nm = f.properties?.emd_kor_nm || f.properties?.name || '';
if (nm) layer.bindTooltip(
(f.properties.type === 'gu' ? '🗺️ ' : '📍 ') + nm,
{ direction: 'center', sticky: true });
}
}).addTo(map);
currentGu = guName;
const src = boundaryService === 'vworld' ? 'VWORLD' : 'Overpass';
setBoundaryStatus(`${features.length - 1}개 경계 로딩됨 (${src})`);
map.fitBounds(boundaryLayer.getBounds().pad(0.05));
} catch (err) {
console.error(err);
setBoundaryStatus('Failed: ' + (err.message || 'Unknown'), 'error');
}
boundaryLoading = false;
}
// ── Boundary UI ──
guSelect.addEventListener('change', () => {
if (!guSelect.value) { clearBoundaries(); return; }
showBoundaries(guSelect.value);
});
document.getElementById('clear-boundary').addEventListener('click', clearBoundaries);
document.getElementById('boundary-toggle').addEventListener('click', () => {
document.getElementById('boundary-body').classList.toggle('open');
document.getElementById('boundary-chevron').classList.toggle('open');
});
</script>
</body>
</html>