Frontend: Export UI, Inline Edit, Proximity, Drift, Walk-Path, Batch, Sessions, Heatmap

This commit is contained in:
2026-05-25 19:12:58 -04:00
parent e11ac3c6f2
commit 80619eb993
+419 -106
View File
@@ -265,7 +265,38 @@
.prev-photo-card .sticker-color {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px;
}
.export-modal-backdrop { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
.export-modal { background: var(--card); border-radius: var(--radius); padding: 20px; max-width: 320px; width: 90%; text-align: center; }
.export-modal h3 { margin-bottom: 12px; }
.export-modal .btn { margin-bottom: 8px; }
.ocr-edit { cursor: pointer; font-weight: 600; color: var(--accent); border-bottom: 1px dashed var(--accent); }
.ocr-input { background: var(--card2); color: var(--text); border: 1px solid var(--accent); border-radius: 6px; padding: 2px 6px; font-size: 12px; width: 140px; }
.drift-ok { color: var(--green); } .drift-warn { color: var(--amber); } .drift-bad { color: var(--red); }
.map-toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 11px; color: var(--text2); }
.map-toggle label { cursor: pointer; display: flex; align-items: center; gap: 4px; }
.map-toggle input[type=checkbox] { accent-color: var(--accent); }
.batch-bar { position: sticky; bottom: 0; background: var(--card); border-top: 2px solid var(--accent); padding: 10px; display: none; align-items: center; gap: 8px; z-index: 100; margin-top: 8px; border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
.batch-bar input[type=text] { flex: 1; background: var(--card2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 12px; }
.bulk-card.batch-mode { padding-left: 4px; }
.bulk-card input[type=checkbox] { accent-color: var(--accent); margin-right: 4px; }
.session-picker { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 12px; color: var(--text2); }
.session-picker select, .session-picker input { background: var(--card2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font-size: 11px; }
.session-card { background: var(--card2); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 6px; cursor: pointer; border-left: 3px solid var(--accent); }
.session-card .sess-name { font-weight: 600; font-size: 13px; }
.session-card .sess-meta { font-size: 10px; color: var(--text3); margin-top: 2px; }
.session-card .sess-photos { margin-top: 6px; display: none; }
.session-card.open .sess-photos { display: block; }
.heatmap-sliders { display: flex; gap: 8px; margin: 4px 0 6px; align-items: center; font-size: 10px; color: var(--text3); }
.heatmap-sliders input[type=range] { flex: 1; accent-color: var(--accent); }
.nearby-line { cursor: pointer; font-size: 11px; margin-top: 2px; }
.nearby-list { font-size: 10px; color: var(--text2); margin-top: 2px; padding-left: 4px; }
.walk-timeline { font-size: 10px; color: var(--text2); margin-top: 4px; max-height: 120px; overflow-y: auto; }
.walk-timeline div { padding: 1px 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
</head>
<body>
@@ -330,6 +361,14 @@
<div id="serverResults"></div>
</div>
<!-- Session picker -->
<div class="session-picker" id="sessionPicker" style="display:none;">
<span>📂 Session:</span>
<input type="text" id="sessionName" placeholder="Building B Floor 2" style="flex:1;" value="">
<select id="sessionBuilding" onchange="onSessionChange()"><option value="">Building...</option></select>
<select id="sessionFloor"><option value="">Floor...</option></select>
<button class="btn btn-xs" style="background:var(--card2);color:var(--text);border:1px solid var(--border);" onclick="createSessionFromPicker()">+ New</button>
</div>
<!-- Bulk process button -->
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
🔍 Bulk Process GPS Photos
@@ -340,14 +379,37 @@
<div class="detail-section">📊 Bulk Results
<span class="match-badge" id="bulkSummary" style="margin-left:auto;"></span>
</div>
<div class="map-toggle" id="mapToggles" style="display:none;">
<label><input type="checkbox" id="walkPathToggle" onchange="toggleWalkPath()"> 🗺️ Walk Path</label>
<label><input type="checkbox" id="heatmapToggle" onchange="toggleHeatmap()"> 🔥 Heatmap</label>
</div>
<div class="heatmap-sliders" id="heatmapSliders" style="display:none;">
<span>Radius</span><input type="range" id="heatRadius" min="5" max="50" value="15" oninput="updateHeatmap()">
<span>Intensity</span><input type="range" id="heatIntensity" min="1" max="10" value="5" oninput="updateHeatmap()">
</div>
<div id="mapContainer"></div>
<div style="display:flex;gap:6px;margin-bottom:6px;">
<button class="btn btn-xs" id="exportBtn" style="background:var(--accent);color:#fff;display:none;" onclick="showExportModal()">📊 Export</button>
<button class="btn btn-xs" id="batchBtn" style="background:transparent;border:1px solid var(--border);color:var(--text2);display:none;" onclick="toggleBatchMode()">📎 Batch Select</button>
</div>
<div id="bulkResults"></div>
<div class="batch-bar" id="batchBar">
<span id="batchCount" style="font-size:11px;white-space:nowrap;">📎 0 selected</span>
<input type="text" id="batchMachineId" placeholder="Machine ID..." disabled>
<button class="btn btn-xs btn-primary" id="batchLookup" disabled onclick="batchLookup()">🔍</button>
<button class="btn btn-xs" id="batchPushGps" style="background:var(--green);color:#000;display:none;" onclick="batchPushGps()">📤 Push GPS</button>
</div>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
</div>
<!-- Sessions -->
<div class="section" id="sessionsSection" style="display:none;">
<div class="detail-section">📋 Sessions <span class="badge badge-info" id="sessionCount">0</span></div>
<div id="sessionsList"></div>
</div>
<!-- Previously processed -->
<div class="section" id="prevSection" style="display:none;">
<div class="detail-section">📂 Previously Processed <span class="badge badge-info" id="prevCount">0</span></div>
@@ -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') +
'<br>📸 ' + esc(item.filename || 'photo') +
'<br>📍 ' + 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: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
.bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
}
const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">';
if (item.thumb) {
html += '<img class="bulk-thumb" src="' + item.thumb + '" alt="">';
}
html += '<div class="bulk-info">';
if (item.duplicate) {
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
html += '<div class="bulk-meta"><span class="match-badge dup">♻️ Already processed</span></div>';
} else if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div>';
html += '<div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
html += '<span class="match-badge ' + (isGpsReady ? 'no-gps' : 'has-gps') + '">';
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 += '</span>';
html += '</div>';
} else if (item.machine_id) {
html += '<div class="bulk-name">Machine #' + esc(item.machine_id) + '</div>';
html += '<div class="bulk-meta"><span class="match-badge no-match">No DB match</span></div>';
} else {
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>';
}
html += '</div>';
html += '<div class="bulk-action">';
if (isGpsReady) {
html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
} else if (asset && asset.latitude && asset.longitude) {
html += '<button class="btn-push done">✓ In DB</button>';
}
html += '</div>';
html += '</div>';
});
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 = '<div class="export-modal"><h3>📊 Export Data</h3><button class="btn btn-primary btn-sm" onclick="doExport(\'csv\')">📊 CSV (Excel)</button><button class="btn btn-sm" style="background:var(--green);color:#000;" onclick="doExport(\'kml\')">🌍 KML (Google Earth)</button><button class="btn btn-outline btn-sm" onclick="doExport(\'clipboard\')">📋 Copy GPS</button><div style="margin-top:8px;font-size:11px;color:var(--text3);" id="exportStatus"></div><button class="btn btn-outline btn-sm" style="margin-top:8px;" onclick="document.getElementById(\'exportModal\').remove()">Cancel</button></div>';
document.body.appendChild(d);
}
async function doExport(f) {
const s = document.getElementById('exportStatus'); s.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span> 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 = '<input class="ocr-input" id="ocrInput' + idx + '" value="' + esc(v) + '" placeholder="Type machine ID...">';
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 = '<span class="ocr-edit" onclick="makeOcrEditable(' + el.dataset.pid + ',' + JSON.stringify(mid) + ',' + idx + ')">' + (mid || 'No OCR match') + '</span>';
}
async function submitMachineId(pid, mid, idx) {
if (!mid || !mid.trim()) return;
const el = document.getElementById('ocrLabel' + idx); if (!el) return;
el.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
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) + ' <span style="font-size:10px;color:var(--text3);">🆔 ' + esc(d.asset.machine_id) + '</span>';
if (d.needs_gps) {
const card = el.closest('.bulk-info');
if (card) { const a = card.parentElement.querySelector('.bulk-action'); if (a) a.innerHTML = '<button class="btn-push" onclick="pushGpsToAssetWithId(' + d.asset.id + ',\'' + d.asset.machine_id + '\',' + pid + ')">📤 Push GPS</button>'; }
}
} else { el.innerHTML = '<span style="color:var(--amber);">⚠️ No asset match</span>'; setTimeout(() => { el.innerHTML = '<span class="ocr-edit" onclick="makeOcrEditable(' + pid + ',\'' + mid + '\',' + idx + ')">' + esc(mid) + '</span>'; }, 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 = '<span class="spinner" style="width:10px;height:10px;"></span>';
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 = '<span style="color:var(--text3);font-size:10px;">📍 No nearby assets</span>'; return; }
el.innerHTML = '<span class="nearby-line" onclick="toggleNearbyList(' + idx + ')">📍 Nearby: <strong>' + d.count + '</strong> within 10m</span><div class="nearby-list" id="nearbyList' + idx + '" style="display:none;"></div>';
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 '<div>' + i + ' 🆔 ' + esc(n.machine_id) + ' · ' + esc(n.name) + ' · ' + n.distance_m + 'm' + (n.has_gps ? ' · 📶 GPS' : ' · ⚠️ no GPS') + '</div>';
}).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 = '<div class="walk-timeline" id="walkTimeline"><strong>🚶 Walk Path</strong><br>';
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 += '<div>' + ic + ' Step ' + (i+1) + '→' + (i+2) + ': ' + d + 'm</div>';
}
th += '</div>';
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 = '<span class="spinner" style="width:10px;height:10px;"></span>';
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 = '<span class="spinner" style="width:10px;height:10px;"></span> 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 => '<div class="session-card" onclick="toggleSession(' + s.id + ')"><div class="sess-name">' + esc(s.name) + '</div><div class="sess-meta">📍 ' + (s.photo_count||0) + ' photos · ' + (s.matched_count||0) + ' matched · ' + (s.needs_gps_count||0) + ' need GPS · ' + (s.closed_at ? '🔒 Closed' : '🟢 Open') + '</div><div class="sess-photos" id="sessPhotos' + s.id + '"></div></div>').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 '<div style="font-size:10px;padding:2px 0;border-bottom:1px solid var(--border);">' + esc(p.orig_filename) + (p.machine_id ? ' · 🆔 ' + esc(p.machine_id) : '') + (c ? ' · ' + c : '') + '</div>'; }).join('');
} catch(e) { pd.innerHTML = '<span style="color:var(--red);">Failed</span>'; }
}
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') + '<br>📸 ' + esc(item.filename||'') + '<br>📍 ' + 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: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>', iconSize: [24,24], iconAnchor: [12,12] })
}).addTo(bulkMap).bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
}
const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '" data-idx="' + i + '">';
if (item.thumb) html += '<img class="bulk-thumb" src="' + item.thumb + '" alt="">';
html += '<div class="bulk-info">';
if (item.duplicate) {
html += '<div class="bulk-name">' + esc(item.filename||'Photo') + '</div><div class="bulk-meta"><span class="match-badge dup">♻️ Already processed</span></div>';
} else if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div><div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
html += '<span class="match-badge ' + (isGpsReady ? 'no-gps' : 'has-gps') + '">';
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 += '</span></div>';
// 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 += '<div style="font-size:10px;margin-top:2px;">' + ic + ' ' + lb + '</div>';
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 += '<div id="nearby' + i + '"></div>';
} else if (item.machine_id) {
html += '<div class="bulk-name">Machine #' + esc(item.machine_id) + '</div><div class="bulk-meta"><span class="match-badge no-match">No DB match</span></div>';
if (gpsLat && gpsLng) html += '<div id="nearby' + i + '"></div>';
} else {
html += '<div class="bulk-name">' + esc(item.filename||'Photo') + '</div><div class="bulk-meta">';
html += '<span class="ocr-edit" id="ocrLabel' + i + '" data-pid="' + (item.photo_id||'') + '" onclick="makeOcrEditable(' + (item.photo_id||'null') + ',' + (item.machine_id ? JSON.stringify(item.machine_id) : 'null') + ',' + i + ')">No OCR match</span>';
html += '</div>';
if (gpsLat && gpsLng) html += '<div id="nearby' + i + '"></div>';
}
html += '</div><div class="bulk-action">';
if (isGpsReady) html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
else if (asset && asset.latitude && asset.longitude) html += '<button class="btn-push done">✓ In DB</button>';
html += '</div></div>';
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'});
}
</script>
</body>
</html>