π Previously Processed 0
@@ -896,112 +958,7 @@ async function startBulkProcess() {
}
}
-function renderBulkResults(summary) {
- const section = document.getElementById('bulkSection');
- section.style.display = 'block';
-
- document.getElementById('bulkSummary').textContent =
- summary.matched + ' matched, ' + summary.needs_gps + ' need GPS' +
- (summary.duplicates ? ', ' + summary.duplicates + ' skipped' : '');
- document.getElementById('bulkSummary').className =
- 'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
-
- const mapDiv = document.getElementById('mapContainer');
- mapDiv.style.display = 'block';
- if (bulkMap) bulkMap.remove();
- bulkMap = L.map('mapContainer').setView([28.35, -81.55], 8);
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution: 'Β© OSM', maxZoom: 19
- }).addTo(bulkMap);
-
- const markers = [];
- let html = '';
-
- bulkData.forEach((item, i) => {
- const asset = item.asset;
- const hasGps = item.exif?.gps;
- const gpsLat = hasGps ? item.exif.gps.lat : null;
- const gpsLng = hasGps ? item.exif.gps.lng : null;
-
- if (gpsLat && gpsLng) {
- const marker = L.marker([gpsLat, gpsLng])
- .addTo(bulkMap)
- .bindPopup(
- (asset ? esc(asset.name) : 'Unknown machine') +
- '
πΈ ' + esc(item.filename || 'photo') +
- '
π ' + gpsLat.toFixed(5) + ', ' + gpsLng.toFixed(5)
- );
- markers.push([gpsLat, gpsLng]);
- }
-
- if (asset && asset.latitude && asset.longitude) {
- L.marker([asset.latitude, asset.longitude], {
- icon: L.divIcon({
- className: 'cat-pin-icon',
- html: '
π
',
- iconSize: [24, 24], iconAnchor: [12, 12]
- })
- }).addTo(bulkMap)
- .bindPopup('
' + esc(asset.name) + 'Existing location');
- }
-
- const isGpsReady = item.needs_gps;
- html += '
';
-
- if (item.thumb) {
- html += '

';
- }
-
- html += '
';
-
- if (item.duplicate) {
- html += '
' + esc(item.filename || 'Photo') + '
';
- html += '
β»οΈ Already processed
';
- } else if (asset) {
- html += '
' + esc(asset.name) + '
';
- html += '
';
- html += 'π ' + esc(asset.machine_id) + ' ';
- html += '';
- if (asset.latitude && asset.longitude) {
- html += 'π Has GPS in DB';
- } else if (isGpsReady) {
- html += 'π Photo GPS: ' + gpsLat.toFixed(4) + ', ' + gpsLng.toFixed(4);
- } else {
- html += 'β οΈ No photo GPS';
- }
- html += '';
- html += '
';
- } else if (item.machine_id) {
- html += '
Machine #' + esc(item.machine_id) + '
';
- html += '
No DB match
';
- } else {
- html += '
' + esc(item.filename || 'Photo') + '
';
- html += '
No OCR match
';
- }
-
- html += '
';
-
- html += '
';
- if (isGpsReady) {
- html += '';
- } else if (asset && asset.latitude && asset.longitude) {
- html += '';
- }
- html += '
';
-
- html += '
';
- });
-
- document.getElementById('bulkResults').innerHTML = html;
-
- if (markers.length) {
- const bounds = L.latLngBounds(markers);
- bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
- }
-
- setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
- section.scrollIntoView({ behavior: 'smooth' });
-}
+/* REPLACED BELOW */
async function pushGpsToAsset(idx) {
const item = bulkData[idx];
@@ -1101,6 +1058,362 @@ function formatSize(bytes) {
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
+
+// ============================================================
+// Feature #4: Export UI
+// ============================================================
+function showExportModal() {
+ const d = document.createElement('div'); d.className = 'export-modal-backdrop'; d.id = 'exportModal';
+ d.onclick = function(e) { if (e.target === this) this.remove(); };
+ d.innerHTML = '
π Export Data
';
+ document.body.appendChild(d);
+}
+async function doExport(f) {
+ const s = document.getElementById('exportStatus'); s.innerHTML = '
Exporting...';
+ try {
+ if (f === 'clipboard') {
+ const r = await fetch('/api/export?format=clipboard&limit=5000'); const d = await r.json();
+ if (d.text) { await navigator.clipboard.writeText(d.text); s.innerHTML = 'β
Copied ' + d.count + ' coordinates!'; }
+ else { s.innerHTML = 'β οΈ No GPS coordinates to copy'; }
+ } else {
+ const r = await fetch('/api/export?format=' + f + '&limit=5000'); const b = await r.blob();
+ const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = 'exif-export-' + new Date().toISOString().slice(0,10) + '.' + f; a.click();
+ URL.revokeObjectURL(u); s.innerHTML = 'β
Downloaded!';
+ }
+ } catch(e) { s.innerHTML = 'β Error: ' + e.message; }
+}
+
+// ============================================================
+// Feature #5: Inline Machine ID Edit
+// ============================================================
+function makeOcrEditable(pid, mid, idx) {
+ const el = document.getElementById('ocrLabel' + idx); if (!el) return;
+ const v = mid || '';
+ el.innerHTML = '
';
+ const inp = document.getElementById('ocrInput' + idx); inp.focus(); inp.select();
+ inp.onkeydown = function(e) {
+ if (e.key === 'Enter') submitMachineId(pid, this.value, idx);
+ if (e.key === 'Escape') cancelEdit(idx, mid);
+ };
+ inp.onblur = function() { if (this.value.trim() !== v.trim()) submitMachineId(pid, this.value, idx); else cancelEdit(idx, mid); };
+}
+function cancelEdit(idx, mid) {
+ const el = document.getElementById('ocrLabel' + idx); if (!el) return;
+ el.innerHTML = '
' + (mid || 'No OCR match') + '';
+}
+async function submitMachineId(pid, mid, idx) {
+ if (!mid || !mid.trim()) return;
+ const el = document.getElementById('ocrLabel' + idx); if (!el) return;
+ el.innerHTML = '
';
+ try {
+ const r = await fetch('/api/assign-machine-id', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({photo_id: pid, machine_id: mid.trim()}) });
+ const d = await r.json();
+ if (d.asset) {
+ el.innerHTML = esc(d.asset.name) + '
π ' + esc(d.asset.machine_id) + '';
+ if (d.needs_gps) {
+ const card = el.closest('.bulk-info');
+ if (card) { const a = card.parentElement.querySelector('.bulk-action'); if (a) a.innerHTML = '
'; }
+ }
+ } else { el.innerHTML = '
β οΈ No asset match'; setTimeout(() => { el.innerHTML = '
' + esc(mid) + ''; }, 2000); }
+ } catch(e) { el.innerHTML = 'β Error'; }
+}
+async function pushGpsToAssetWithId(aid, mid, pid) {
+ if (!aid) return;
+ const r = await fetch('/api/photos/' + pid); const p = await r.json();
+ if (!p.gps_lat || !p.gps_lng) { alert('No GPS'); return; }
+ const btn = event?.target; if (btn) { btn.disabled = true; btn.textContent = 'β³'; }
+ try {
+ const r2 = await fetch('/api/push-gps', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({asset_id: aid, latitude: p.gps_lat, longitude: p.gps_lng}) });
+ const d = await r2.json();
+ if (btn) { btn.className = d.updated ? 'btn-push done' : 'btn-push'; btn.textContent = d.updated ? 'β Pushed!' : (d.reason || 'Failed'); }
+ } catch(e) { if (btn) { btn.textContent = 'Error'; btn.style.background = 'var(--red)'; } }
+}
+
+// ============================================================
+// Feature #6: GPS Proximity
+// ============================================================
+let _nearbyCache = {};
+async function loadNearby(lat, lng, pid, idx) {
+ const key = lat + ',' + lng;
+ if (_nearbyCache[key]) { renderNearby(_nearbyCache[key], idx); return; }
+ const el = document.getElementById('nearby' + idx); if (!el) return;
+ el.innerHTML = '
';
+ try {
+ const r = await fetch('/api/nearby?lat=' + lat + '&lng=' + lng + '&radius=10' + (pid ? '&exclude_photo_id=' + pid : ''));
+ const d = await r.json(); _nearbyCache[key] = d; renderNearby(d, idx);
+ if (typeof L !== 'undefined' && bulkMap && d.count > 0) L.circle([lat, lng], {radius: 10, color: '#22c55e', fillOpacity: 0.05, weight: 1}).addTo(bulkMap);
+ } catch(e) {}
+}
+function renderNearby(d, idx) {
+ const el = document.getElementById('nearby' + idx); if (!el) return;
+ if (d.count === 0) { el.innerHTML = '
π No nearby assets'; return; }
+ el.innerHTML = '
π Nearby: ' + d.count + ' within 10m';
+ el.dataset.nearbyData = JSON.stringify(d.nearby);
+}
+function toggleNearbyList(idx) {
+ const l = document.getElementById('nearbyList' + idx); if (!l) return;
+ if (l.style.display !== 'none') { l.style.display = 'none'; return; }
+ l.style.display = 'block'; if (l.dataset.loaded) return; l.dataset.loaded = '1';
+ const el = document.getElementById('nearby' + idx); if (!el || !el.dataset.nearbyData) return;
+ l.innerHTML = JSON.parse(el.dataset.nearbyData).map(n => {
+ const i = n.distance_m <= 5 ? 'π’' : n.distance_m <= 20 ? 'π‘' : 'π΄';
+ return '
' + i + ' π ' + esc(n.machine_id) + ' Β· ' + esc(n.name) + ' Β· ' + n.distance_m + 'm' + (n.has_gps ? ' Β· πΆ GPS' : ' Β· β οΈ no GPS') + '
';
+ }).join('');
+}
+
+// ============================================================
+// Feature #10: GPS Drift Check
+// ============================================================
+function haversineKm(lat1, lng1, lat2, lng2) {
+ const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLng = (lng2-lng1)*Math.PI/180;
+ const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+}
+
+// ============================================================
+// Feature #7: Walk-Path Timeline
+// ============================================================
+let _walkPathGroup = null;
+function toggleWalkPath() {
+ const en = document.getElementById('walkPathToggle').checked;
+ if (!bulkMap || !bulkData.length) return;
+ if (_walkPathGroup) { bulkMap.removeLayer(_walkPathGroup); _walkPathGroup = null; }
+ const wt = document.getElementById('walkTimeline'); if (wt) wt.remove();
+ if (!en) return;
+ const pts = bulkData.map((item,i) => ({lat: item.exif?.gps?.lat || item.clientLat, lng: item.exif?.gps?.lng || item.clientLng, name: item.asset?.name || item.filename || 'Photo '+(i+1), i})).filter(p => p.lat && p.lng);
+ if (pts.length < 2) { document.getElementById('walkPathToggle').checked = false; return; }
+ _walkPathGroup = L.layerGroup().addTo(bulkMap);
+ let th = '
πΆ Walk Path';
+ for (let i = 0; i < pts.length-1; i++) {
+ const d = Math.round(haversineKm(pts[i].lat, pts[i].lng, pts[i+1].lat, pts[i+1].lng) * 1000);
+ const c = d < 30 ? '#22c55e' : d < 120 ? '#f59e0b' : '#ef4444';
+ const ic = d < 30 ? 'π’' : d < 120 ? 'π‘' : 'π΄';
+ L.polyline([[pts[i].lat, pts[i].lng], [pts[i+1].lat, pts[i+1].lng]], {color: c, weight: 3, opacity: 0.7}).addTo(_walkPathGroup).bindPopup('Walked ' + d + 'm from ' + esc(pts[i].name) + ' to ' + esc(pts[i+1].name));
+ th += '
' + ic + ' Step ' + (i+1) + 'β' + (i+2) + ': ' + d + 'm
';
+ }
+ th += '
';
+ const con = document.getElementById('bulkResults'); if (con) con.insertAdjacentHTML('beforebegin', th);
+}
+
+// ============================================================
+// Feature #8: Batch Machine ID Assign
+// ============================================================
+let _batchMode = false, _batchSelected = {};
+function toggleBatchMode() {
+ _batchMode = !_batchMode; _batchSelected = {};
+ const bar = document.getElementById('batchBar'), btn = document.getElementById('batchBtn');
+ if (_batchMode) {
+ bar.style.display = 'flex'; btn.textContent = 'β
Done Selecting';
+ document.querySelectorAll('.bulk-card').forEach(c => c.classList.add('batch-mode'));
+ document.querySelectorAll('.bulk-card').forEach((c, i) => {
+ if (!c.querySelector('input[type=checkbox]')) {
+ const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.cssText = 'accent-color:var(--accent);margin-right:4px;';
+ cb.onchange = function() { onBatchSelect(i, this.checked); }; c.insertBefore(cb, c.firstChild);
+ }
+ });
+ } else {
+ bar.style.display = 'none'; btn.textContent = 'π Batch Select';
+ document.querySelectorAll('.bulk-card input[type=checkbox]').forEach(c => c.remove());
+ document.querySelectorAll('.bulk-card').forEach(c => c.classList.remove('batch-mode'));
+ document.getElementById('batchMachineId').disabled = true; document.getElementById('batchLookup').disabled = true; document.getElementById('batchPushGps').style.display = 'none';
+ }
+}
+function onBatchSelect(idx, checked) {
+ if (checked) _batchSelected[idx] = true; else delete _batchSelected[idx];
+ const c = Object.keys(_batchSelected).length;
+ document.getElementById('batchCount').textContent = 'π ' + c + ' selected';
+ document.getElementById('batchMachineId').disabled = c < 2; document.getElementById('batchLookup').disabled = c < 2; document.getElementById('batchPushGps').style.display = 'none';
+}
+async function batchLookup() {
+ const mid = document.getElementById('batchMachineId').value.trim(); if (!mid) return;
+ const s = document.getElementById('batchCount'); s.innerHTML = '
';
+ try {
+ const r = await fetch('/api/lookup?machine_id=' + encodeURIComponent(mid)); const d = await r.json();
+ if (d.found) { s.innerHTML = 'β
' + esc(d.asset.name) + ' β π€ Push ready'; document.getElementById('batchPushGps').style.display = ''; document.getElementById('batchPushGps').dataset.machineId = mid; }
+ else { s.innerHTML = 'β οΈ No asset match'; document.getElementById('batchPushGps').style.display = 'none'; }
+ } catch(e) { s.innerHTML = 'β Error'; }
+}
+async function batchPushGps() {
+ const mid = document.getElementById('batchPushGps').dataset.machineId;
+ const pids = Object.keys(_batchSelected).map(k => bulkData[parseInt(k)]?.photo_id).filter(Boolean);
+ if (!pids.length || !mid) return;
+ const s = document.getElementById('batchCount'); s.innerHTML = '
Assigning...';
+ try {
+ const r = await fetch('/api/bulk-assign', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({photo_ids: pids, machine_id: mid}) });
+ const d = await r.json(); s.innerHTML = 'β
Updated ' + d.updated + ', GPS pushed: ' + d.gps_pushed + (d.no_gps?.length ? ', no GPS: ' + d.no_gps.length : '');
+ _batchMode = false; toggleBatchMode(); loadPreviousPhotos();
+ } catch(e) { s.innerHTML = 'β Error: ' + e.message; }
+}
+
+// ============================================================
+// Feature #9: Session Management UI
+// ============================================================
+let _currentSessionId = null;
+async function loadSessions() {
+ try {
+ const r = await fetch('/api/sessions'); const d = await r.json();
+ const sec = document.getElementById('sessionsSection'), lst = document.getElementById('sessionsList');
+ if (!d.sessions || !d.sessions.length) { if (sec) sec.style.display = 'none'; return; }
+ if (sec) sec.style.display = 'block';
+ if (document.getElementById('sessionCount')) document.getElementById('sessionCount').textContent = d.sessions.length;
+ if (lst) lst.innerHTML = d.sessions.map(s => '
' + esc(s.name) + '
π ' + (s.photo_count||0) + ' photos Β· ' + (s.matched_count||0) + ' matched Β· ' + (s.needs_gps_count||0) + ' need GPS Β· ' + (s.closed_at ? 'π Closed' : 'π’ Open') + '
').join('');
+ } catch(e) {}
+}
+async function toggleSession(sid) {
+ const pd = document.getElementById('sessPhotos' + sid); if (!pd) return;
+ const card = pd.closest('.session-card'); if (!card) return;
+ if (card.classList.contains('open')) { card.classList.remove('open'); return; }
+ if (!pd.innerHTML) {
+ try {
+ const r = await fetch('/api/sessions/' + sid); const d = await r.json();
+ pd.innerHTML = (d.photos || []).map(p => { const c = p.gps_lat ? 'π ' + Number(p.gps_lat).toFixed(4) + ', ' + Number(p.gps_lng).toFixed(4) : ''; return '
' + esc(p.orig_filename) + (p.machine_id ? ' Β· π ' + esc(p.machine_id) : '') + (c ? ' Β· ' + c : '') + '
'; }).join('');
+ } catch(e) { pd.innerHTML = '
Failed'; }
+ }
+ card.classList.add('open');
+}
+async function createSessionFromPicker() {
+ const n = document.getElementById('sessionName').value.trim(), b = document.getElementById('sessionBuilding').value, f = document.getElementById('sessionFloor').value;
+ if (!n) { alert('Enter a session name first'); return; }
+ try {
+ const r = await fetch('/api/sessions', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: n, building: b||undefined, floor: f||undefined}) });
+ const d = await r.json(); _currentSessionId = d.id; document.getElementById('sessionName').style.borderColor = 'var(--green)'; loadSessions();
+ } catch(e) { alert('Failed to create session'); }
+}
+function onSessionChange() {}
+
+// ============================================================
+// Feature #11: Coverage Heatmap
+// ============================================================
+let _heatLayer = null;
+function toggleHeatmap() {
+ const en = document.getElementById('heatmapToggle').checked;
+ const sl = document.getElementById('heatmapSliders');
+ if (sl) sl.style.display = en ? 'flex' : 'none';
+ if (!en) { if (_heatLayer && bulkMap) { bulkMap.removeLayer(_heatLayer); _heatLayer = null; } return; }
+ updateHeatmap();
+}
+function updateHeatmap() {
+ if (_heatLayer && bulkMap) { bulkMap.removeLayer(_heatLayer); _heatLayer = null; }
+ if (!document.getElementById('heatmapToggle')?.checked) return;
+ const r = parseInt(document.getElementById('heatRadius').value), inf = parseInt(document.getElementById('heatIntensity').value) / 5;
+ const pts = bulkData.map(it => { const lt = it.exif?.gps?.lat || it.clientLat, ln = it.exif?.gps?.lng || it.clientLng; return (lt && ln) ? [lt, ln, inf] : null; }).filter(Boolean);
+ if (pts.length && typeof L !== 'undefined' && bulkMap && L.heatLayer) {
+ _heatLayer = L.heatLayer(pts, {radius: r, blur: r*0.8, maxZoom: 18, gradient: {0.0:'blue', 0.3:'cyan', 0.5:'lime', 0.8:'yellow', 1.0:'red'}}).addTo(bulkMap);
+ }
+}
+
+// ============================================================
+// OVERRIDE: renderBulkResults (new comprehensive version)
+// ============================================================
+function renderBulkResults(summary) {
+ const section = document.getElementById('bulkSection');
+ section.style.display = 'block';
+
+ document.getElementById('bulkSummary').textContent =
+ summary.matched + ' matched, ' + summary.needs_gps + ' need GPS' +
+ (summary.duplicates ? ', ' + summary.duplicates + ' skipped' : '');
+ document.getElementById('bulkSummary').className =
+ 'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
+
+ const mapDiv = document.getElementById('mapContainer');
+ mapDiv.style.display = 'block';
+ if (bulkMap) { try { bulkMap.remove(); } catch(e) {} bulkMap = null; }
+ bulkMap = L.map('mapContainer').setView([28.35, -81.55], 8);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: 'Β© OSM', maxZoom: 19 }).addTo(bulkMap);
+
+ const mt = document.getElementById('mapToggles'); if (mt) mt.style.display = 'flex';
+ const eb = document.getElementById('exportBtn'); if (eb) eb.style.display = '';
+ const bb = document.getElementById('batchBtn'); if (bb) bb.style.display = '';
+ const sp = document.getElementById('sessionPicker'); if (sp) sp.style.display = 'flex';
+ loadSessions();
+
+ if (_walkPathGroup) { try { bulkMap.removeLayer(_walkPathGroup); } catch(e) {} _walkPathGroup = null; }
+ if (_heatLayer) { try { bulkMap.removeLayer(_heatLayer); } catch(e) {} _heatLayer = null; }
+ const wpt = document.getElementById('walkPathToggle'); if (wpt) wpt.checked = false;
+ const ht = document.getElementById('heatmapToggle'); if (ht) ht.checked = false;
+ const hs = document.getElementById('heatmapSliders'); if (hs) hs.style.display = 'none';
+ const wtim = document.getElementById('walkTimeline'); if (wtim) wtim.remove();
+
+ const markers = [];
+ let html = '';
+
+ bulkData.forEach((item, i) => {
+ const asset = item.asset;
+ const hasGps = item.exif?.gps;
+ const gpsLat = hasGps ? item.exif.gps.lat : null;
+ const gpsLng = hasGps ? item.exif.gps.lng : null;
+
+ if (gpsLat && gpsLng) {
+ markers.push([gpsLat, gpsLng]);
+ L.marker([gpsLat, gpsLng]).addTo(bulkMap).bindPopup(
+ (asset ? esc(asset.name) : 'Unknown') + '
πΈ ' + esc(item.filename||'') + '
π ' + gpsLat.toFixed(5) + ', ' + gpsLng.toFixed(5));
+ }
+
+ if (asset && asset.latitude && asset.longitude) {
+ L.marker([asset.latitude, asset.longitude], {
+ icon: L.divIcon({ className: 'cat-pin-icon', html: '
π
', iconSize: [24,24], iconAnchor: [12,12] })
+ }).addTo(bulkMap).bindPopup('
' + esc(asset.name) + 'Existing location');
+ }
+
+ const isGpsReady = item.needs_gps;
+ html += '
';
+ if (item.thumb) html += '

';
+ html += '
';
+
+ if (item.duplicate) {
+ html += '
' + esc(item.filename||'Photo') + '
β»οΈ Already processed
';
+ } else if (asset) {
+ html += '
' + esc(asset.name) + '
';
+ html += 'π ' + esc(asset.machine_id) + ' ';
+ html += '';
+ if (asset.latitude && asset.longitude) html += 'π Has GPS in DB';
+ else if (isGpsReady) html += 'π Photo GPS: ' + gpsLat.toFixed(4) + ', ' + gpsLng.toFixed(4);
+ else html += 'β οΈ No photo GPS';
+ html += '
';
+ // Drift check
+ if (gpsLat && gpsLng && asset.latitude && asset.longitude) {
+ const dm = haversineKm(gpsLat, gpsLng, asset.latitude, asset.longitude) * 1000;
+ let ic = 'π’', lb = 'GPS OK';
+ if (dm > 50) { ic = 'π΄'; lb = 'β οΈβ οΈ Drift ' + Math.round(dm) + 'm'; }
+ else if (dm > 10) { ic = 'π‘'; lb = 'β οΈ Drift ' + Math.round(dm) + 'm'; }
+ html += '
' + ic + ' ' + lb + '
';
+ if (typeof L !== 'undefined' && bulkMap) {
+ const c = dm > 50 ? '#ef4444' : dm > 10 ? '#f59e0b' : '#22c55e';
+ setTimeout(() => { try { L.polyline([[gpsLat, gpsLng], [asset.latitude, asset.longitude]], {color:c, weight:1, dashArray:'3,3', opacity:0.4}).addTo(bulkMap); } catch(e) {} }, 300);
+ }
+ }
+ if (gpsLat && gpsLng) html += '
';
+ } else if (item.machine_id) {
+ html += '
Machine #' + esc(item.machine_id) + '
No DB match
';
+ if (gpsLat && gpsLng) html += '
';
+ } else {
+ html += '
' + esc(item.filename||'Photo') + '
';
+ html += 'No OCR match';
+ html += '
';
+ if (gpsLat && gpsLng) html += '
';
+ }
+
+ html += '
';
+ if (isGpsReady) html += '';
+ else if (asset && asset.latitude && asset.longitude) html += '';
+ html += '
';
+
+ if (gpsLat && gpsLng) setTimeout(() => loadNearby(gpsLat, gpsLng, item.photo_id, i), 500);
+ });
+
+ document.getElementById('bulkResults').innerHTML = html;
+ if (_batchMode) {
+ document.querySelectorAll('.bulk-card').forEach((c, i) => {
+ if (!c.querySelector('input[type=checkbox]')) {
+ const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.cssText = 'accent-color:var(--accent);margin-right:4px;';
+ cb.onchange = function() { onBatchSelect(i, this.checked); }; c.insertBefore(cb, c.firstChild);
+ }
+ });
+ }
+ if (markers.length) bulkMap.fitBounds(L.latLngBounds(markers), {padding:[30,30], maxZoom:15});
+ setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
+ section.scrollIntoView({behavior:'smooth'});
+}