Add name edit modal, photo viewer, marker fly-to, and fix tile fallback
This commit is contained in:
161
index.html
161
index.html
@@ -123,8 +123,15 @@ body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color:
|
|||||||
width: 22px; height: 22px; border-radius: 50%;
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
background: #e2e8f0; color: #4a5568;
|
background: #e2e8f0; color: #4a5568;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
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 {
|
.flag-header .status-dot {
|
||||||
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -145,6 +152,8 @@ body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color:
|
|||||||
border: none; border-radius: 4px; cursor: pointer; font-size: 11px;
|
border: none; border-radius: 4px; cursor: pointer; font-size: 11px;
|
||||||
padding: 4px 8px;
|
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 { background: #2b6cb0; color: #fff; }
|
||||||
.flag-header .actions .place-btn:hover { background: #1a4f8b; }
|
.flag-header .actions .place-btn:hover { background: #1a4f8b; }
|
||||||
.flag-header .actions .remove-btn { background: #e53e3e; color: #fff; }
|
.flag-header .actions .remove-btn { background: #e53e3e; color: #fff; }
|
||||||
@@ -164,8 +173,9 @@ body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color:
|
|||||||
.photo-preview { margin-bottom: 6px; max-width: 100%; border-radius: 6px; overflow: hidden; }
|
.photo-preview { margin-bottom: 6px; max-width: 100%; border-radius: 6px; overflow: hidden; }
|
||||||
.photo-preview img {
|
.photo-preview img {
|
||||||
max-width: 100%; max-height: 140px; display: block; border-radius: 6px;
|
max-width: 100%; max-height: 140px; display: block; border-radius: 6px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.photo-preview img:hover { opacity: 0.85; }
|
||||||
.photo-actions { display: flex; gap: 6px; align-items: center; }
|
.photo-actions { display: flex; gap: 6px; align-items: center; }
|
||||||
.photo-actions .photo-btn {
|
.photo-actions .photo-btn {
|
||||||
padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px;
|
padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
@@ -186,6 +196,40 @@ body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color:
|
|||||||
|
|
||||||
.help-text { font-size: 12px; color: #718096; margin-top: 6px; }
|
.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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -242,6 +286,21 @@ body { font-family: 'Segoe UI', sans-serif; display: flex; height: 100vh; color:
|
|||||||
|
|
||||||
<div id="map"></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>
|
<script>
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// ─── VWORLD API Key ──────────────────────────────────────
|
// ─── VWORLD API Key ──────────────────────────────────────
|
||||||
@@ -302,35 +361,15 @@ function updateApiStatus(className, text) {
|
|||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tileFallbackDone = false;
|
|
||||||
|
|
||||||
function setupTileLayer(key) {
|
function setupTileLayer(key) {
|
||||||
if (tileLayer) map.removeLayer(tileLayer);
|
if (tileLayer) map.removeLayer(tileLayer);
|
||||||
tileFallbackDone = false;
|
|
||||||
|
|
||||||
if (key) {
|
if (key) {
|
||||||
tileLayer = L.tileLayer(
|
tileLayer = L.tileLayer(
|
||||||
`https://api.vworld.kr/req/wmts/1.0.0/${key}/Base/{z}/{y}/{x}.png`,
|
`https://api.vworld.kr/req/wmts/1.0.0/${key}/Base/{z}/{y}/{x}.png`,
|
||||||
{ maxZoom: 20, attribution: 'VWORLD' }
|
{ maxZoom: 19, attribution: 'VWORLD' }
|
||||||
);
|
);
|
||||||
tileService = '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: '© OpenStreetMap contributors' }
|
|
||||||
).addTo(map);
|
|
||||||
tileService = 'osm';
|
|
||||||
updateApiStatus('warn', 'OSM (VWORLD 타일 실패)');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
tileLayer = L.tileLayer(
|
tileLayer = L.tileLayer(
|
||||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
@@ -341,8 +380,8 @@ function setupTileLayer(key) {
|
|||||||
|
|
||||||
tileLayer.addTo(map);
|
tileLayer.addTo(map);
|
||||||
updateApiStatus(
|
updateApiStatus(
|
||||||
key && tileService === 'vworld' ? 'ok' : 'off',
|
key ? 'ok' : 'off',
|
||||||
tileService === 'vworld' ? 'VWORLD' : 'OSM'
|
key ? 'VWORLD' : 'OSM'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +555,7 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<span class="idx">${displayIdx}</span>
|
<span class="idx${flag.marker ? ' has-marker' : ''}">${displayIdx}</span>
|
||||||
<span class="status-dot ${flag.placed ? 'placed' : 'unplaced'}"></span>
|
<span class="status-dot ${flag.placed ? 'placed' : 'unplaced'}"></span>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${esc(flag.name)}</div>
|
<div class="name">${esc(flag.name)}</div>
|
||||||
@@ -524,6 +563,7 @@ function renderList() {
|
|||||||
</div>
|
</div>
|
||||||
<span class="chevron ${flag.expanded ? 'open' : ''}">▾</span>
|
<span class="chevron ${flag.expanded ? 'open' : ''}">▾</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button class="edit-btn" data-action="edit-name">✎</button>
|
||||||
${!flag.placed
|
${!flag.placed
|
||||||
? `<button class="place-btn">Place</button>`
|
? `<button class="place-btn">Place</button>`
|
||||||
: `<button class="remove-btn">✕</button>`}
|
: `<button class="remove-btn">✕</button>`}
|
||||||
@@ -559,8 +599,18 @@ function renderList() {
|
|||||||
(e) => handlePhotoUpload(flag.id, e.target.files[0]));
|
(e) => handlePhotoUpload(flag.id, e.target.files[0]));
|
||||||
body.querySelector('[data-action="remove-photo"]')?.addEventListener('click',
|
body.querySelector('[data-action="remove-photo"]')?.addEventListener('click',
|
||||||
() => removePhoto(flag.id));
|
() => removePhoto(flag.id));
|
||||||
|
body.querySelector('.photo-preview img')?.addEventListener('click', () => {
|
||||||
|
openPhotoViewer(flag.photoPath);
|
||||||
|
});
|
||||||
li.appendChild(body);
|
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) => {
|
header.querySelector('.place-btn')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation(); selectedFlagId = flag.id; renderList();
|
e.stopPropagation(); selectedFlagId = flag.id; renderList();
|
||||||
});
|
});
|
||||||
@@ -592,6 +642,65 @@ function addFlag(name, number) {
|
|||||||
return 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 Click ──────────────────────────────────────────
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user