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;
|
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>
|
</style>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
<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 id="prevResults"></div>
|
||||||
</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>
|
<script>
|
||||||
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
|
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
|
||||||
let selectedIdx = -1;
|
let selectedIdx = -1;
|
||||||
@@ -506,8 +563,17 @@ let bulkData = [];
|
|||||||
let _lastPhotoId = null; // stored by uploadSelected for manualLookup assign
|
let _lastPhotoId = null; // stored by uploadSelected for manualLookup assign
|
||||||
let _lastPhotoGps = null; // {lat, lng} for manualLookup push
|
let _lastPhotoGps = null; // {lat, lng} for manualLookup push
|
||||||
|
|
||||||
// On load: fetch previously processed photos
|
// EXIF Photos Map globals
|
||||||
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
|
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() {
|
function loadPreviousPhotos() {
|
||||||
fetch('/api/photos?limit=30')
|
fetch('/api/photos?limit=30')
|
||||||
@@ -2125,6 +2191,240 @@ function _applyLightboxTransform(img) {
|
|||||||
img.classList.add('zoomed');
|
img.classList.add('zoomed');
|
||||||
img.style.cursor = 'grab';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user