Files
MapAssignFlag/index.html

808 lines
28 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: 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: '&copy; 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 =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[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>