Frontend: Export UI, Inline Edit, Proximity, Drift, Walk-Path, Batch, Sessions, Heatmap
This commit is contained in:
+419
-106
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user