Add machine ID search bar to EXIF Photos Map with highlight zoom
- Search input + Find/Clear buttons above the map - Partial match search against all marker machine_ids - Matched markers get amber pulsing highlight icon (22px) - Auto-zoom to fit matched markers - Clear button restores original view - Store _exifGpsPhotos globally for search - Store machineId on each marker at creation time - Store default icon reference for proper restore on clear - CSS: animated amber pulse ring around highlighted markers
This commit is contained in:
+302
-2
@@ -365,6 +365,33 @@ if ('serviceWorker' in navigator) {
|
||||
pointer-events: none; transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
/* EXIF Photos Map markers */
|
||||
.exif-marker-icon {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
.exif-marker-dot {
|
||||
width: 12px; height: 12px;
|
||||
background: var(--accent);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
.exif-marker-highlight {
|
||||
width: 20px !important; height: 20px !important;
|
||||
background: #f59e0b !important;
|
||||
border: 3px solid #fff !important;
|
||||
box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5) !important;
|
||||
animation: exifSearchPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes exifSearchPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(245,158,11,0.25), 0 2px 12px rgba(0,0,0,0.6); }
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
@@ -497,6 +524,36 @@ if ('serviceWorker' in navigator) {
|
||||
<div id="prevResults"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
SECTION: EXIF Photos Map — all GPS-tagged photos on a map
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="section" id="exifMapSection">
|
||||
<div class="detail-section">
|
||||
📍 EXIF Photos Map
|
||||
<span class="badge badge-info" id="exifMapCount">0</span>
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--accent);color:var(--accent);float:right;" onclick="loadExifMap()">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="map-toggle" style="margin-bottom:6px;">
|
||||
<label><input type="checkbox" id="exifHeatmapToggle" onchange="toggleExifHeatmap()"> 🔥 Heatmap</label>
|
||||
<span style="font-size:10px;color:var(--text3);margin-left:auto;" id="exifMapInfo">Loading...</span>
|
||||
</div>
|
||||
<div class="exif-map-search" style="display:flex;gap:6px;margin-bottom:6px;">
|
||||
<input type="text" id="exifMachineSearch" placeholder="🔍 Search machine ID..." style="flex:1;padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--card2);color:var(--text);font-size:13px;outline:none;" onkeydown="if(event.key==='Enter') searchExifMapByMachine()">
|
||||
<button class="btn btn-xs" style="background:var(--accent);color:#fff;border:none;white-space:nowrap;" onclick="searchExifMapByMachine()">🔎 Find</button>
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--border);color:var(--text2);white-space:nowrap;" onclick="clearExifSearch()">✕ Clear</button>
|
||||
</div>
|
||||
<div id="exifSearchStatus" style="font-size:11px;color:var(--text3);margin-bottom:4px;min-height:16px;"></div>
|
||||
<div id="exifMapContainer" style="height:350px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:8px;"></div>
|
||||
<div id="exifPhotoCountRow" style="display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--text2);">
|
||||
<span>📸 <span id="exifTotalPhotos">0</span> total</span>
|
||||
<span>📍 <span id="exifGpsPhotos" style="color:var(--green);font-weight:600;">0</span> with GPS</span>
|
||||
<span>⚠️ <span id="exifNoGpsPhotos" style="color:var(--amber);">0</span> no GPS</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<button class="btn btn-outline btn-xs" onclick="openExifMapFullscreen()" style="width:100%;">🗺️ Open Full Map</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
|
||||
let selectedIdx = -1;
|
||||
@@ -506,8 +563,17 @@ let bulkData = [];
|
||||
let _lastPhotoId = null; // stored by uploadSelected for manualLookup assign
|
||||
let _lastPhotoGps = null; // {lat, lng} for manualLookup push
|
||||
|
||||
// On load: fetch previously processed photos
|
||||
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
|
||||
// EXIF Photos Map globals
|
||||
let exifMap = null;
|
||||
let exifHeatLayer = null;
|
||||
let exifMarkers = []; // L.marker objects (parallel to _exifGpsPhotos)
|
||||
let _exifGpsPhotos = []; // photo data with GPS, used for search
|
||||
|
||||
// On load: fetch previously processed photos + EXIF map
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPreviousPhotos();
|
||||
setTimeout(loadExifMap, 500);
|
||||
});
|
||||
|
||||
function loadPreviousPhotos() {
|
||||
fetch('/api/photos?limit=30')
|
||||
@@ -2125,6 +2191,240 @@ function _applyLightboxTransform(img) {
|
||||
img.classList.add('zoomed');
|
||||
img.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// EXIF Photos Map — all GPS-tagged photos plotted on Leaflet
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function loadExifMap() {
|
||||
document.getElementById('exifMapInfo').textContent = 'Loading...';
|
||||
fetch('/api/photos?limit=200')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const photos = data.photos || [];
|
||||
updateExifMapCounts(photos);
|
||||
renderExifMapPins(photos);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('exifMapInfo').textContent = '❌ Failed to load';
|
||||
console.warn('EXIF map load error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function updateExifMapCounts(photos) {
|
||||
const total = photos.length;
|
||||
const withGps = photos.filter(p => p.gps_lat && p.gps_lng).length;
|
||||
const withoutGps = total - withGps;
|
||||
document.getElementById('exifTotalPhotos').textContent = total;
|
||||
document.getElementById('exifGpsPhotos').textContent = withGps;
|
||||
document.getElementById('exifNoGpsPhotos').textContent = withoutGps;
|
||||
document.getElementById('exifMapCount').textContent = withGps + ' with GPS';
|
||||
}
|
||||
|
||||
function renderExifMapPins(photos) {
|
||||
// Get or init the map
|
||||
const container = document.getElementById('exifMapContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Destroy old map if exists
|
||||
if (exifMap) {
|
||||
exifMap.remove();
|
||||
exifMap = null;
|
||||
exifMarkers = [];
|
||||
_exifGpsPhotos = [];
|
||||
if (exifHeatLayer) {
|
||||
exifHeatLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const gpsPhotos = photos.filter(p => p.gps_lat && p.gps_lng);
|
||||
_exifGpsPhotos = gpsPhotos; // store globally for search
|
||||
if (gpsPhotos.length === 0) {
|
||||
document.getElementById('exifMapInfo').textContent = 'No GPS photos found — upload some!';
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:13px;">📍 No GPS-tagged photos yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Init map
|
||||
exifMap = L.map(container, { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap'
|
||||
}).addTo(exifMap);
|
||||
|
||||
// Add markers
|
||||
const bounds = [];
|
||||
const heatData = [];
|
||||
|
||||
gpsPhotos.forEach((p) => {
|
||||
const lat = Number(p.gps_lat);
|
||||
const lng = Number(p.gps_lng);
|
||||
if (isNaN(lat) || isNaN(lng)) return;
|
||||
|
||||
bounds.push([lat, lng]);
|
||||
heatData.push([lat, lng, 0.8]);
|
||||
|
||||
// Custom icon: colored circle with photo count
|
||||
const icon = L.divIcon({
|
||||
className: 'exif-marker-icon',
|
||||
html: '<div class="exif-marker-dot"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
popupAnchor: [0, -10],
|
||||
});
|
||||
|
||||
const marker = L.marker([lat, lng], { icon })
|
||||
.addTo(exifMap)
|
||||
.bindPopup(`
|
||||
<div style="min-width:180px;font-family:-apple-system,sans-serif;">
|
||||
<img src="/api/photos/${p.id}/file" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;margin-bottom:6px;" loading="lazy"
|
||||
onerror="this.style.display='none'">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:2px;">${esc(p.orig_filename || '')}</div>
|
||||
<div style="font-size:11px;color:#666;">📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
|
||||
<div style="font-size:10px;color:#999;margin-top:2px;">
|
||||
${p.machine_id ? '🆔 ' + esc(p.machine_id) : 'No machine ID'}
|
||||
${p.created_at ? ' · ' + new Date(p.created_at).toLocaleDateString() : ''}
|
||||
</div>
|
||||
${p.ocr_match_5dash6 ? '<div style="font-size:10px;color:#666;margin-top:2px;">🔍 OCR: ' + esc(p.ocr_match_5dash6) + '</div>' : ''}
|
||||
</div>
|
||||
`, { maxWidth: 260 });
|
||||
|
||||
marker._defaultIcon = icon;
|
||||
marker.machineId = (p.machine_id || '').toLowerCase();
|
||||
marker.photoData = p;
|
||||
exifMarkers.push(marker);
|
||||
});
|
||||
|
||||
// Fit bounds to show all markers
|
||||
if (bounds.length > 0) {
|
||||
const group = L.featureGroup(exifMarkers);
|
||||
exifMap.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
// Invalidate size after render
|
||||
setTimeout(() => {
|
||||
if (exifMap) exifMap.invalidateSize();
|
||||
}, 200);
|
||||
|
||||
document.getElementById('exifMapInfo').textContent = gpsPhotos.length + ' photo' + (gpsPhotos.length !== 1 ? 's' : '') + ' on map';
|
||||
|
||||
// Pre-compute heatmap data for toggle
|
||||
exifHeatLayer = L.heatLayer(heatData, {
|
||||
radius: 20, blur: 15, maxZoom: 17, max: 1.0, gradient: { 0.2: '#3b82f6', 0.5: '#22c55e', 0.8: '#f59e0b', 1.0: '#ef4444' }
|
||||
});
|
||||
if (document.getElementById('exifHeatmapToggle').checked) {
|
||||
exifHeatLayer.addTo(exifMap);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExifHeatmap() {
|
||||
if (!exifMap || !exifHeatLayer) return;
|
||||
if (document.getElementById('exifHeatmapToggle').checked) {
|
||||
exifMap.addLayer(exifHeatLayer);
|
||||
} else {
|
||||
exifMap.removeLayer(exifHeatLayer);
|
||||
}
|
||||
}
|
||||
|
||||
function openExifMapFullscreen() {
|
||||
if (!exifMap) {
|
||||
loadExifMap();
|
||||
// Scroll to map after a brief delay
|
||||
setTimeout(() => {
|
||||
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
|
||||
setTimeout(() => {
|
||||
if (exifMap) exifMap.invalidateSize();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// ── Machine ID search on EXIF Map ──
|
||||
|
||||
function searchExifMapByMachine() {
|
||||
const input = document.getElementById('exifMachineSearch');
|
||||
const status = document.getElementById('exifSearchStatus');
|
||||
const query = (input.value || '').trim().toLowerCase();
|
||||
if (!query) { status.textContent = '🔍 Enter a machine ID to search'; return; }
|
||||
if (!exifMap || exifMarkers.length === 0) {
|
||||
status.textContent = '⚠️ Map not loaded yet — click Refresh';
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore default icons on any previously highlighted markers
|
||||
clearExifHighlight();
|
||||
|
||||
// Find matching markers
|
||||
const matched = [];
|
||||
exifMarkers.forEach((marker) => {
|
||||
if (marker.machineId && marker.machineId.includes(query)) {
|
||||
matched.push(marker);
|
||||
}
|
||||
});
|
||||
|
||||
if (matched.length === 0) {
|
||||
status.textContent = '❌ No photos found with machine ID matching "' + esc(input.value) + '"';
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight matched markers with a larger, pulsing icon
|
||||
const highlightIcon = L.divIcon({
|
||||
className: 'exif-marker-icon exif-marker-search',
|
||||
html: '<div class="exif-marker-dot exif-marker-highlight"></div>',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
popupAnchor: [0, -14],
|
||||
});
|
||||
|
||||
const highlightGroup = L.featureGroup();
|
||||
matched.forEach(marker => {
|
||||
marker._searchHighlight = highlightIcon;
|
||||
marker.setIcon(highlightIcon);
|
||||
highlightGroup.addLayer(marker);
|
||||
});
|
||||
|
||||
window._exifSearchGroup = highlightGroup;
|
||||
exifMap.fitBounds(highlightGroup.getBounds().pad(0.3));
|
||||
|
||||
// Build status message
|
||||
let matchInfo = '🔎 Found <strong>' + matched.length + '</strong> photo' + (matched.length !== 1 ? 's' : '');
|
||||
const uniqueIds = [...new Set(matched.map(m => m.photoData.machine_id).filter(Boolean))];
|
||||
if (uniqueIds.length > 0) {
|
||||
matchInfo += ' • 🆔 ' + uniqueIds.map(id => '<code>' + esc(id) + '</code>').join(', ');
|
||||
}
|
||||
status.innerHTML = matchInfo;
|
||||
}
|
||||
|
||||
function clearExifHighlight() {
|
||||
if (!exifMarkers) return;
|
||||
exifMarkers.forEach(m => {
|
||||
if (m._searchHighlight) {
|
||||
m.setIcon(m._defaultIcon);
|
||||
m._searchHighlight = null;
|
||||
}
|
||||
});
|
||||
if (window._exifSearchGroup) {
|
||||
exifMap.removeLayer(window._exifSearchGroup);
|
||||
window._exifSearchGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearExifSearch() {
|
||||
document.getElementById('exifMachineSearch').value = '';
|
||||
document.getElementById('exifSearchStatus').textContent = '';
|
||||
clearExifHighlight();
|
||||
// Fit all markers back
|
||||
if (exifMap && exifMarkers.length > 0) {
|
||||
const group = L.featureGroup(exifMarkers);
|
||||
exifMap.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
}
|
||||
|
||||
// Also re-load EXIF map when new photos are processed (after loadPreviousPhotos completes)
|
||||
function refreshExifMap() {
|
||||
setTimeout(loadExifMap, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user