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:
2026-05-26 17:41:39 -04:00
parent bbc5b41868
commit ec7478b287
+302 -2
View File
@@ -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: '&copy; 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>