1075 lines
45 KiB
HTML
1075 lines
45 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; cursor: pointer;
|
|
position: relative;
|
|
}
|
|
.flag-header .idx::after {
|
|
content: ''; position: absolute;
|
|
inset: -6px; border-radius: 50%;
|
|
}
|
|
.flag-header .idx:hover { background: #cbd5e0; }
|
|
.flag-header .idx.has-marker { background: #2b6cb0; color: #fff; }
|
|
.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 .edit-btn { background: #e2e8f0; color: #4a5568; }
|
|
.flag-header .actions .edit-btn:hover { background: #cbd5e0; }
|
|
.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; cursor: pointer;
|
|
}
|
|
.photo-preview img:hover { opacity: 0.85; }
|
|
.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; }
|
|
|
|
.modal-overlay {
|
|
display: none; position: fixed; inset: 0; z-index: 9999;
|
|
background: rgba(0,0,0,0.4); align-items: center; justify-content: center;
|
|
}
|
|
.modal-overlay.open { display: flex; }
|
|
.modal-box {
|
|
background: #fff; border-radius: 10px; padding: 20px 24px;
|
|
min-width: 320px; max-width: 420px; box-shadow: 0 8px 30px rgba(0,0,0,0.2);
|
|
}
|
|
.modal-box h3 { margin-bottom: 12px; font-size: 16px; }
|
|
.modal-box input {
|
|
width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px;
|
|
font-size: 14px; margin-bottom: 16px; box-sizing: border-box;
|
|
}
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
.modal-actions button {
|
|
padding: 6px 16px; border: 1px solid #aaa; border-radius: 6px;
|
|
background: #fff; cursor: pointer; font-size: 13px;
|
|
}
|
|
.modal-actions button:hover { background: #e9ecef; }
|
|
.modal-actions .btn-apply { background: #2b6cb0; color: #fff; border-color: #2b6cb0; }
|
|
.modal-actions .btn-apply:hover { background: #1a4f8b !important; }
|
|
|
|
.photo-overlay {
|
|
display: none; position: fixed; inset: 0; z-index: 99999;
|
|
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
|
|
cursor: zoom-out;
|
|
}
|
|
.photo-overlay.open { display: flex; }
|
|
.photo-overlay img {
|
|
max-width: 90vw; max-height: 90vh; border-radius: 8px;
|
|
box-shadow: 0 4px 40px rgba(0,0,0,0.5); cursor: default;
|
|
}
|
|
</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>
|
|
|
|
<div class="modal-overlay" id="name-edit-modal">
|
|
<div class="modal-box">
|
|
<h3>Edit Name</h3>
|
|
<input type="text" id="name-edit-input" />
|
|
<div class="modal-actions">
|
|
<button id="name-edit-cancel">Cancel</button>
|
|
<button id="name-edit-apply" class="btn-apply">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="photo-overlay" id="photo-overlay">
|
|
<img id="photo-overlay-img" src="" alt="Photo" />
|
|
</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;
|
|
}
|
|
|
|
function setupTileLayer(key) {
|
|
if (tileLayer) map.removeLayer(tileLayer);
|
|
|
|
if (key) {
|
|
tileLayer = L.tileLayer(
|
|
`https://api.vworld.kr/req/wmts/1.0.0/${key}/Base/{z}/{y}/{x}.png`,
|
|
{ maxZoom: 19, attribution: 'VWORLD' }
|
|
);
|
|
tileService = 'vworld';
|
|
} else {
|
|
tileLayer = L.tileLayer(
|
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
{ maxZoom: 19, attribution: '© OpenStreetMap contributors' }
|
|
);
|
|
tileService = 'osm';
|
|
}
|
|
|
|
tileLayer.addTo(map);
|
|
updateApiStatus(
|
|
key ? 'ok' : 'off',
|
|
key ? '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 =>
|
|
({'&':'&','<':'<','>':'>','"':'"'})[c]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ─── Photo ─────────────────────────────────────────────
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function handlePhotoUpload(flagId, file) {
|
|
const flag = flags.find(f => f.id === flagId);
|
|
if (!flag || !file) return;
|
|
// remove old photo from server first
|
|
if (flag.photoPath) await fetch(flag.photoPath, { method: 'DELETE' }).catch(() => {});
|
|
const fd = new FormData();
|
|
fd.append('photo', file);
|
|
try {
|
|
const resp = await fetch('/upload', { method: 'POST', body: fd });
|
|
const data = await resp.json();
|
|
flag.photoPath = data.path;
|
|
} catch { flag.photoPath = null; }
|
|
renderList();
|
|
}
|
|
|
|
async function removePhoto(flagId) {
|
|
const flag = flags.find(f => f.id === flagId);
|
|
if (!flag) return;
|
|
if (flag.photoPath) await fetch(flag.photoPath, { method: 'DELETE' }).catch(() => {});
|
|
flag.photoPath = 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${flag.marker ? ' has-marker' : ''}">${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">
|
|
<button class="edit-btn" data-action="edit-name">✎</button>
|
|
${!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.photoPath) bHTML += `<div class="photo-preview"><img src="${flag.photoPath}" alt="Photo" /></div>`;
|
|
bHTML += `<div class="photo-actions">
|
|
<label class="photo-btn">${flag.photoPath ? 'Change Photo' : 'Upload Photo'}
|
|
<input type="file" accept="image/*" class="photo-input" />
|
|
</label>`;
|
|
if (flag.photoPath) 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));
|
|
body.querySelector('.photo-preview img')?.addEventListener('click', () => {
|
|
openPhotoViewer(flag.photoPath);
|
|
});
|
|
li.appendChild(body);
|
|
|
|
header.querySelector('.idx')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (flag.marker) map.flyTo(flag.marker.getLatLng(), 17);
|
|
});
|
|
header.querySelector('[data-action="edit-name"]')?.addEventListener('click', (e) => {
|
|
e.stopPropagation(); openNameEdit(flag.id);
|
|
});
|
|
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, photoPath: null };
|
|
flags.push(flag);
|
|
return flag;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ─── Name Edit Modal ─────────────────────────────────────
|
|
// ═══════════════════════════════════════════════════════════
|
|
let editingFlagId = null;
|
|
|
|
function openNameEdit(flagId) {
|
|
const flag = flags.find(f => f.id === flagId);
|
|
if (!flag) return;
|
|
editingFlagId = flagId;
|
|
document.getElementById('name-edit-input').value = flag.name;
|
|
document.getElementById('name-edit-modal').classList.add('open');
|
|
}
|
|
|
|
document.getElementById('name-edit-apply').addEventListener('click', () => {
|
|
const flag = flags.find(f => f.id === editingFlagId);
|
|
if (!flag) return;
|
|
const val = document.getElementById('name-edit-input').value.trim();
|
|
if (val) {
|
|
flag.name = val;
|
|
if (flag.marker) {
|
|
flag.marker.unbindTooltip();
|
|
flag.marker.bindTooltip(esc(flag.name), { permanent: true, direction: 'right', offset: [10, 0], className: 'flag-marker-tooltip' });
|
|
}
|
|
renderList();
|
|
}
|
|
document.getElementById('name-edit-modal').classList.remove('open');
|
|
editingFlagId = null;
|
|
});
|
|
|
|
document.getElementById('name-edit-cancel').addEventListener('click', () => {
|
|
document.getElementById('name-edit-modal').classList.remove('open');
|
|
editingFlagId = null;
|
|
});
|
|
|
|
document.getElementById('name-edit-modal').addEventListener('click', (e) => {
|
|
if (e.target === e.currentTarget) {
|
|
document.getElementById('name-edit-modal').classList.remove('open');
|
|
editingFlagId = null;
|
|
}
|
|
});
|
|
|
|
document.getElementById('name-edit-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') document.getElementById('name-edit-apply').click();
|
|
if (e.key === 'Escape') document.getElementById('name-edit-cancel').click();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ─── Photo Viewer ────────────────────────────────────────
|
|
// ═══════════════════════════════════════════════════════════
|
|
function openPhotoViewer(path) {
|
|
if (!path) return;
|
|
document.getElementById('photo-overlay-img').src = path;
|
|
document.getElementById('photo-overlay').classList.add('open');
|
|
}
|
|
|
|
document.getElementById('photo-overlay').addEventListener('click', () => {
|
|
document.getElementById('photo-overlay').classList.remove('open');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ─── 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());
|
|
|
|
const photo = row.Photo || row.photo || '';
|
|
if (photo) flag.photoPath = String(photo);
|
|
|
|
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 ?? '',
|
|
Photo: f.photoPath ?? '',
|
|
}));
|
|
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 }, { wch: 30 }];
|
|
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>
|