Add name edit modal, photo viewer, marker fly-to, and fix tile fallback

This commit is contained in:
2026-05-16 02:32:47 +09:00
parent 9a5f6cc185
commit f5a1e49d20

View File

@@ -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: '&copy; 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 ──────────────────────────────────────────
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════