Compare commits
11 Commits
ac7a8b1553
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec7478b287 | |||
| bbc5b41868 | |||
| e3d8481f4f | |||
| 5488f579e7 | |||
| a243980c1b | |||
| 22c2acf0da | |||
| 6c54912cfc | |||
| db45b386fa | |||
| 23f479dd0c | |||
| 04ac2fd7c7 | |||
| 9512819e3e |
@@ -0,0 +1,69 @@
|
||||
# EXIF Test — Agent Guide
|
||||
|
||||
EXIF + OCR test backend that validates GPS data survives the upload pipeline. Photo upload with metadata preservation, OCR machine ID reading, session management, and GPS proximity search.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python** 3.11+, **FastAPI**, **uvicorn**
|
||||
- **Pillow**, **pillow-heif** — image processing (JPEG, HEIC/HEIF)
|
||||
- **pytesseract** — Tesseract OCR
|
||||
- **Optional LLM OCR** — OpenCode Go / Google Gemini
|
||||
- **Frontend:** vanilla JS SPA, IndexedDB offline queue
|
||||
- **Service worker** (`static/sw.js`) — offline photo queue
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
server.py # ~1.5K lines — ALL FastAPI routes in one file
|
||||
new_endpoints.py # Placeholder for future endpoints
|
||||
FEATURE_PLAN.md # ~629 lines — comprehensive roadmap (✅ done / 💡 proposed)
|
||||
requirements.txt
|
||||
test_e2e_smoke.sh # End-to-end smoke test
|
||||
|
||||
static/
|
||||
index.html # SPA frontend (~79KB)
|
||||
sw.js # Service worker for offline photo queue
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Dev (with reload)
|
||||
uvicorn server:app --reload --host 0.0.0.0 --port 8903
|
||||
|
||||
# Production
|
||||
python server.py
|
||||
# or
|
||||
uvicorn server:app --host 0.0.0.0 --port 8903
|
||||
```
|
||||
|
||||
## Key Architecture
|
||||
|
||||
- **Single-file backend** — `server.py` has all routes
|
||||
- Hotels dotenv from `~/.hermes/.env`
|
||||
- Uses `photos.db` for photo/session metadata
|
||||
- Loads machine data from `canteen-asset-tracker/assets.db`
|
||||
- Supports Tesseract and optional LLM-based OCR (OpenCode Go, Google Gemini)
|
||||
- Frontend has IndexedDB offline queue for uploading photos without connection
|
||||
|
||||
## Features
|
||||
|
||||
- Photo upload with EXIF metadata extraction
|
||||
- OCR machine ID reading (ConnectID sticker)
|
||||
- Session-based grouping
|
||||
- GPS proximity search
|
||||
- Export: CSV, KML, clipboard
|
||||
- Bulk assign to machines
|
||||
|
||||
## Production
|
||||
|
||||
- **Port:** 8903
|
||||
- **No systemd**
|
||||
- **Dev only** — `--reload` in dev scripts
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **No requirements.txt committed** — dependencies inferred from imports
|
||||
- **Single-file backend** — server.py is ~1.5K lines
|
||||
- LLM-based OCR requires OpenCode Go or Gemini configured in environment
|
||||
- HEIC support requires `pillow-heif` + system libheif
|
||||
+808
-32
@@ -162,6 +162,14 @@ if ('serviceWorker' in navigator) {
|
||||
}
|
||||
.filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* Toggle switch */
|
||||
.switch { position: relative; display: inline-block; width: 32px; height: 18px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; inset: 0; background: var(--card2); border: 1px solid var(--border); border-radius: 18px; transition: .2s; }
|
||||
.slider::before { content: ''; position: absolute; height: 12px; width: 12px; left: 2px; bottom: 2px; background: var(--text3); border-radius: 50%; transition: .2s; }
|
||||
.switch input:checked + .slider { background: var(--accent); border-color: var(--accent); }
|
||||
.switch input:checked + .slider::before { transform: translateX(14px); background: #fff; }
|
||||
|
||||
.asset-card {
|
||||
background: var(--card2); border-radius: var(--radius-sm);
|
||||
padding: 12px; margin-top: 10px; border-left: 3px solid var(--green);
|
||||
@@ -273,6 +281,26 @@ if ('serviceWorker' in navigator) {
|
||||
.prev-photo-card .sticker-color {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px;
|
||||
}
|
||||
.prev-thumb {
|
||||
width: 100%; aspect-ratio: 1; object-fit: cover;
|
||||
border-radius: var(--radius-sm); margin-bottom: 8px;
|
||||
cursor: zoom-in; display: block; background: var(--card2);
|
||||
}
|
||||
/* Inline machine ID editing in Previous Photos */
|
||||
.prev-mid-editable {
|
||||
cursor: text; border-bottom: 1px dashed var(--text3);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.prev-mid-editable:hover { border-color: var(--accent); color: var(--text); }
|
||||
.prev-mid-editable::after { content: ' ✏️'; font-size: 9px; opacity: 0.5; }
|
||||
.prev-mid-editable:hover::after { opacity: 1; }
|
||||
.prev-mid-input {
|
||||
background: var(--card2); color: var(--text); border: 1px solid var(--accent);
|
||||
border-radius: 6px; padding: 2px 6px; font-size: 11px; font-weight: 700;
|
||||
width: 100px; outline: none;
|
||||
}
|
||||
.prev-mid-input:focus { border-color: var(--accent2); box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }
|
||||
|
||||
.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; }
|
||||
@@ -302,6 +330,68 @@ if ('serviceWorker' in navigator) {
|
||||
.walk-timeline { font-size: 10px; color: var(--text2); margin-top: 4px; max-height: 120px; overflow-y: auto; }
|
||||
.walk-timeline div { padding: 1px 0; }
|
||||
|
||||
/* Lightbox — full-screen photo viewer with zoom */
|
||||
#lightbox {
|
||||
display: none; position: fixed; inset: 0; z-index: 99999;
|
||||
background: rgba(0,0,0,0.95);
|
||||
touch-action: none; user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
#lightbox.active { display: flex; align-items: center; justify-content: center; }
|
||||
#lightboxClose {
|
||||
position: fixed; top: 12px; right: 12px; z-index: 100000;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15); border: none;
|
||||
color: #fff; font-size: 20px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#lightboxClose:active { background: rgba(255,255,255,0.3); }
|
||||
#lightboxContainer {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
#lightboxImage {
|
||||
max-width: 100%; max-height: 100%;
|
||||
object-fit: contain; cursor: zoom-in;
|
||||
transition: none;
|
||||
will-change: transform;
|
||||
}
|
||||
#lightboxImage.zoomed { cursor: grab; }
|
||||
#lightboxImage.zoomed:active { cursor: grabbing; }
|
||||
#lightboxZoomHint {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.4); font-size: 11px;
|
||||
pointer-events: none; transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
/* EXIF Photos Map markers */
|
||||
.exif-marker-icon {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
.exif-marker-dot {
|
||||
width: 12px; height: 12px;
|
||||
background: var(--accent);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
.exif-marker-highlight {
|
||||
width: 20px !important; height: 20px !important;
|
||||
background: #f59e0b !important;
|
||||
border: 3px solid #fff !important;
|
||||
box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5) !important;
|
||||
animation: exifSearchPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes exifSearchPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(245,158,11,0.25), 0 2px 12px rgba(0,0,0,0.6); }
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
@@ -344,6 +434,13 @@ if ('serviceWorker' in navigator) {
|
||||
<div><div class="summary-num summary-total" id="sumTotal">0</div><div class="summary-label">Total</div></div>
|
||||
<div><div class="summary-num summary-gps" id="sumGps">0</div><div class="summary-label">📍 Have GPS</div></div>
|
||||
<div><div class="summary-num summary-nogps" id="sumNoGps">0</div><div class="summary-label">⚠️ No GPS</div></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;justify-content:center;">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="gpsToggle" onchange="renderGallery()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span style="font-size:11px;color:var(--text2);white-space:nowrap;">📍 GPS only</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +456,7 @@ if ('serviceWorker' in navigator) {
|
||||
|
||||
<!-- Detail card -->
|
||||
<div id="detail">
|
||||
<img id="detailPreview" alt="">
|
||||
<img id="detailPreview" alt="" onclick="openLightbox(this.src)" style="cursor:zoom-in;">
|
||||
<div id="detailExif"></div>
|
||||
<div style="margin-top:10px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="uploadSelected()">🔍 Upload & Run OCR</button>
|
||||
@@ -427,15 +524,56 @@ if ('serviceWorker' in navigator) {
|
||||
<div id="prevResults"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
SECTION: EXIF Photos Map — all GPS-tagged photos on a map
|
||||
═══════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="section" id="exifMapSection">
|
||||
<div class="detail-section">
|
||||
📍 EXIF Photos Map
|
||||
<span class="badge badge-info" id="exifMapCount">0</span>
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--accent);color:var(--accent);float:right;" onclick="loadExifMap()">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="map-toggle" style="margin-bottom:6px;">
|
||||
<label><input type="checkbox" id="exifHeatmapToggle" onchange="toggleExifHeatmap()"> 🔥 Heatmap</label>
|
||||
<span style="font-size:10px;color:var(--text3);margin-left:auto;" id="exifMapInfo">Loading...</span>
|
||||
</div>
|
||||
<div class="exif-map-search" style="display:flex;gap:6px;margin-bottom:6px;">
|
||||
<input type="text" id="exifMachineSearch" placeholder="🔍 Search machine ID..." style="flex:1;padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--card2);color:var(--text);font-size:13px;outline:none;" onkeydown="if(event.key==='Enter') searchExifMapByMachine()">
|
||||
<button class="btn btn-xs" style="background:var(--accent);color:#fff;border:none;white-space:nowrap;" onclick="searchExifMapByMachine()">🔎 Find</button>
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--border);color:var(--text2);white-space:nowrap;" onclick="clearExifSearch()">✕ Clear</button>
|
||||
</div>
|
||||
<div id="exifSearchStatus" style="font-size:11px;color:var(--text3);margin-bottom:4px;min-height:16px;"></div>
|
||||
<div id="exifMapContainer" style="height:350px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:8px;"></div>
|
||||
<div id="exifPhotoCountRow" style="display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--text2);">
|
||||
<span>📸 <span id="exifTotalPhotos">0</span> total</span>
|
||||
<span>📍 <span id="exifGpsPhotos" style="color:var(--green);font-weight:600;">0</span> with GPS</span>
|
||||
<span>⚠️ <span id="exifNoGpsPhotos" style="color:var(--amber);">0</span> no GPS</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<button class="btn btn-outline btn-xs" onclick="openExifMapFullscreen()" style="width:100%;">🗺️ Open Full Map</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
|
||||
let selectedIdx = -1;
|
||||
let currentFilter = 'all';
|
||||
let bulkMap = null;
|
||||
let bulkData = [];
|
||||
let _lastPhotoId = null; // stored by uploadSelected for manualLookup assign
|
||||
let _lastPhotoGps = null; // {lat, lng} for manualLookup push
|
||||
|
||||
// On load: fetch previously processed photos
|
||||
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
|
||||
// EXIF Photos Map globals
|
||||
let exifMap = null;
|
||||
let exifHeatLayer = null;
|
||||
let exifMarkers = []; // L.marker objects (parallel to _exifGpsPhotos)
|
||||
let _exifGpsPhotos = []; // photo data with GPS, used for search
|
||||
|
||||
// On load: fetch previously processed photos + EXIF map
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPreviousPhotos();
|
||||
setTimeout(loadExifMap, 500);
|
||||
});
|
||||
|
||||
function loadPreviousPhotos() {
|
||||
fetch('/api/photos?limit=30')
|
||||
@@ -449,8 +587,10 @@ function loadPreviousPhotos() {
|
||||
div.innerHTML = data.photos.map(p => renderPrevCard(p)).join('');
|
||||
// Init maps and asset lookups after rendering
|
||||
data.photos.forEach(p => {
|
||||
if (p.gps_lat && p.gps_lng && p.machine_id) {
|
||||
if (p.gps_lat && p.gps_lng) {
|
||||
initPrevMap(p);
|
||||
}
|
||||
if (p.machine_id) {
|
||||
lookupPrevAsset(p);
|
||||
}
|
||||
});
|
||||
@@ -510,21 +650,23 @@ function lookupPrevAsset(p) {
|
||||
|
||||
function renderPrevCard(p) {
|
||||
const hasMatch = p.machine_id ? 'matched' : 'no-match';
|
||||
const matchText = p.machine_id || (p.ocr_match_5plus ? 'digits found' : 'no match');
|
||||
const matchText = p.machine_id ? 'matched' : (p.ocr_match_5plus ? 'digits found' : 'no match');
|
||||
const engine = p.ocr_engine || 'tesseract';
|
||||
const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : engine === 'google' ? 'llm' : 'tesseract';
|
||||
const time = p.created_at ? new Date(p.created_at + 'Z').toLocaleString() : '';
|
||||
const colorHtml = p.sticker_color
|
||||
? `<span class="sticker-color" style="background:${p.sticker_color === 'green' ? 'var(--green)' : p.sticker_color === 'orange' ? 'var(--amber)' : p.sticker_color === 'yellow' ? '#eab308' : 'var(--text3)'}"></span>`
|
||||
: '';
|
||||
const hasCoords = p.gps_lat && p.gps_lng;
|
||||
const hasCoords = p.gps_lat && p.gps_lng;
|
||||
const photoUrl = `/api/photos/${p.id}/file`;
|
||||
return `<div class="prev-photo-card">
|
||||
<img class="prev-thumb" src="${photoUrl}" alt="" onclick="openLightbox('${photoUrl}')" loading="lazy">
|
||||
<div class="prev-header">
|
||||
<span class="prev-filename">${esc(p.orig_filename)}</span>
|
||||
<span class="prev-time">${time}</span>
|
||||
</div>
|
||||
<div class="prev-details">
|
||||
Machine: <strong>${p.machine_id ? esc(p.machine_id) : '—'}</strong>
|
||||
Machine: <span id="midSpan${p.id}" class="prev-mid-editable" data-mid="${esc(p.machine_id || '')}" onclick="editMachineIdInline(${p.id})">${esc(p.machine_id || '—')}</span>
|
||||
<span class="match-badge ${hasMatch}">${esc(matchText)}</span>
|
||||
<span class="engine-badge ${engCls}">${esc(engine)}${p.ocr_model ? ' ' + esc(p.ocr_model) : ''}</span>
|
||||
${colorHtml}
|
||||
@@ -549,22 +691,149 @@ function renderPrevCard(p) {
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--red);color:var(--red);" onclick="deletePhoto(${p.id})">🗑️</button>
|
||||
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--amber);color:var(--amber);" onclick="resetPhoto(${p.id})">🔄 Reset</button>
|
||||
</div>
|
||||
${hasCoords && p.machine_id ? `<div style="margin-top:6px;">
|
||||
<div id="prevMap${p.id}" class="mini-map" style="height:120px;border-radius:6px;background:var(--card2);"></div>
|
||||
<div id="prevAsset${p.id}" style="font-size:11px;margin-top:4px;color:var(--text2);"></div>
|
||||
<div id="prevMachineInfo${p.id}" style="margin-top:2px;"></div>
|
||||
</div>` : hasCoords ? `<div style="margin-top:6px;">
|
||||
<div id="prevMap${p.id}" class="mini-map" style="height:100px;border-radius:6px;background:var(--card2);"></div>
|
||||
${hasCoords ? `<div style="margin-top:6px;">
|
||||
<div id="prevMap${p.id}" class="mini-map" style="height:${p.machine_id ? 120 : 100}px;border-radius:6px;background:var(--card2);"></div>
|
||||
</div>` : ''}
|
||||
<div id="prevAsset${p.id}" class="prev-asset-info" style="font-size:11px;margin-top:4px;color:var(--text2);"></div>
|
||||
<div id="prevMachineInfo${p.id}" class="prev-asset-info" style="margin-top:2px;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Inline machine ID editing in Previous Photos ── */
|
||||
function editMachineIdInline(photoId) {
|
||||
const span = document.getElementById('midSpan' + photoId);
|
||||
if (!span) return;
|
||||
const currentVal = span.dataset.mid || '';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'prev-mid-input';
|
||||
input.value = currentVal;
|
||||
input.placeholder = 'Enter machine ID';
|
||||
input.dataset.photoId = photoId;
|
||||
input.dataset.original = currentVal;
|
||||
span.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
input.addEventListener('blur', () => saveMachineIdInline(input, photoId));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') input.blur();
|
||||
if (e.key === 'Escape') { saveMachineIdInline(input, photoId, true); }
|
||||
});
|
||||
}
|
||||
|
||||
function saveMachineIdInline(input, photoId, cancel) {
|
||||
const orig = input.dataset.original || '';
|
||||
const val = cancel ? orig : input.value.trim();
|
||||
// Restore span first so UI is consistent
|
||||
function restoreSpan(mid, label) {
|
||||
const span = document.createElement('span');
|
||||
span.id = 'midSpan' + photoId;
|
||||
span.className = 'prev-mid-editable';
|
||||
span.dataset.mid = mid || '';
|
||||
span.textContent = label || mid || '—';
|
||||
span.onclick = () => editMachineIdInline(photoId);
|
||||
input.replaceWith(span);
|
||||
return span;
|
||||
}
|
||||
if (cancel || val === orig) {
|
||||
restoreSpan(orig, orig || '—');
|
||||
return;
|
||||
}
|
||||
fetch('/api/assign-machine-id', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ photo_id: photoId, machine_id: val })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
restoreSpan(val, val);
|
||||
// Update the badge
|
||||
const card = document.getElementById('midSpan' + photoId)?.closest('.prev-photo-card');
|
||||
if (card) {
|
||||
const badge = card.querySelector('.match-badge');
|
||||
if (badge) {
|
||||
badge.textContent = 'matched';
|
||||
badge.className = 'match-badge matched';
|
||||
}
|
||||
}
|
||||
// Update asset info — create elements if they don't exist yet
|
||||
let assetEl = document.getElementById('prevAsset' + photoId);
|
||||
let infoEl = document.getElementById('prevMachineInfo' + photoId);
|
||||
if (!assetEl && card) {
|
||||
assetEl = document.createElement('div');
|
||||
assetEl.id = 'prevAsset' + photoId;
|
||||
assetEl.style.cssText = 'font-size:11px;margin-top:4px;color:var(--text2);';
|
||||
card.appendChild(assetEl);
|
||||
}
|
||||
if (!infoEl && card) {
|
||||
infoEl = document.createElement('div');
|
||||
infoEl.id = 'prevMachineInfo' + photoId;
|
||||
infoEl.style.cssText = 'margin-top:2px;';
|
||||
card.appendChild(infoEl);
|
||||
}
|
||||
if (data.asset) {
|
||||
const a = data.asset;
|
||||
if (assetEl) {
|
||||
assetEl.innerHTML = '🆔 ' + esc(a.machine_id) + ' · <strong>' + esc(a.name) + '</strong>' +
|
||||
(a.category ? ' · 📦 ' + esc(a.category) : '') +
|
||||
(a.status === 'active' ? ' 🟢 Active' : '');
|
||||
}
|
||||
if (infoEl) {
|
||||
let info = '';
|
||||
if (a.building_name) info += '🏢 ' + esc(a.building_name) + ' ';
|
||||
if (a.floor) info += '📶 Floor ' + esc(a.floor) + ' ';
|
||||
if (a.room) info += '🚪 ' + esc(a.room) + ' ';
|
||||
if (a.address) info += '🏠 ' + esc(a.address);
|
||||
if (a.make || a.model) info += '🔧 ' + esc(a.make || '') + ' ' + esc(a.model || '');
|
||||
infoEl.innerHTML = info || '(No location details)';
|
||||
}
|
||||
} else {
|
||||
if (assetEl) assetEl.innerHTML = '⚠️ No asset match for <strong>' + esc(val) + '</strong>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
restoreSpan(orig, orig || '—');
|
||||
// Show error toast
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:var(--red);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999;animation:fadeIn 0.2s;';
|
||||
toast.textContent = 'Failed to save machine ID';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function resetPhoto(photoId) {
|
||||
if (!confirm('Reset this entry? The photo will be deleted from the database and can be re-uploaded as new.')) return;
|
||||
if (!confirm('Reset this entry and re-process from scratch?')) return;
|
||||
|
||||
// Find the card to get the original filename
|
||||
const card = document.querySelector(`#reEng${photoId}`)?.closest('.prev-photo-card');
|
||||
const filename = card?.querySelector('.prev-filename')?.textContent?.trim() || 'photo.jpg';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Reset failed');
|
||||
loadPreviousPhotos();
|
||||
// 1. Fetch the original file BEFORE reset deletes it
|
||||
const fileResp = await fetch(`/api/photos/${photoId}/file`);
|
||||
if (!fileResp.ok) throw new Error('Could not load photo file');
|
||||
const blob = await fileResp.blob();
|
||||
|
||||
// 2. Reset in DB (deletes DB record + file from disk)
|
||||
const resetResp = await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' });
|
||||
if (!resetResp.ok) throw new Error('Reset failed');
|
||||
|
||||
// 3. Rebuild a File object and scan it like a fresh upload
|
||||
const file = new File([blob], filename, { type: blob.type || 'image/jpeg' });
|
||||
const photoData = await scanPhoto(file);
|
||||
photoData.file = file;
|
||||
|
||||
// 4. Clear current state and add to gallery
|
||||
resetAll();
|
||||
allPhotos = [photoData];
|
||||
updateSummary();
|
||||
renderGallery();
|
||||
|
||||
// 5. Show the detail view with preview image + manual entry
|
||||
showDetail(0);
|
||||
document.getElementById('serverSection').style.display = 'none';
|
||||
document.getElementById('detail').scrollIntoView({ behavior: 'smooth' });
|
||||
} catch (e) {
|
||||
alert('Reset failed: ' + e.message);
|
||||
}
|
||||
@@ -574,9 +843,31 @@ async function resetAndReupload(photoId) {
|
||||
if (!photoId) { alert('No photo ID available'); return; }
|
||||
if (!confirm('Reset this entry and re-process as fresh?')) return;
|
||||
try {
|
||||
// Try to use local file first, fall back to fetching from server
|
||||
let file = null;
|
||||
if (selectedIdx >= 0 && allPhotos[selectedIdx]?.file) {
|
||||
file = allPhotos[selectedIdx].file;
|
||||
} else {
|
||||
const fileResp = await fetch(`/api/photos/${photoId}/file`);
|
||||
if (fileResp.ok) {
|
||||
const blob = await fileResp.blob();
|
||||
file = new File([blob], 'photo.jpg', { type: blob.type || 'image/jpeg' });
|
||||
}
|
||||
}
|
||||
if (!file) throw new Error('No photo file available');
|
||||
|
||||
await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' });
|
||||
// Re-trigger the upload
|
||||
uploadSelected();
|
||||
|
||||
// Scan and show in detail view
|
||||
const photoData = await scanPhoto(file);
|
||||
photoData.file = file;
|
||||
resetAll();
|
||||
allPhotos = [photoData];
|
||||
updateSummary();
|
||||
renderGallery();
|
||||
showDetail(0);
|
||||
document.getElementById('serverSection').style.display = 'none';
|
||||
document.getElementById('detail').scrollIntoView({ behavior: 'smooth' });
|
||||
} catch (e) {
|
||||
alert('Reset failed: ' + e.message);
|
||||
}
|
||||
@@ -679,10 +970,17 @@ function updateSummary() {
|
||||
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function updateFilterChips() {
|
||||
const chipMap = { all: 'All', gps: '📍 GPS', nogps: '⚠️ No GPS' };
|
||||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
||||
const target = Array.from(document.querySelectorAll('.filter-chip'))
|
||||
.find(c => c.textContent.trim() === chipMap[currentFilter]);
|
||||
if (target) target.classList.add('active');
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
currentFilter = f;
|
||||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
updateFilterChips();
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
@@ -691,6 +989,15 @@ function renderGallery() {
|
||||
if (currentFilter === 'gps') filtered = allPhotos.filter(p => p.hasGps);
|
||||
else if (currentFilter === 'nogps') filtered = allPhotos.filter(p => !p.hasGps);
|
||||
|
||||
// Apply GPS-only toggle (overrides chips for hide-no-gps use case)
|
||||
const gpsToggle = document.getElementById('gpsToggle');
|
||||
if (gpsToggle && gpsToggle.checked) {
|
||||
filtered = filtered.filter(p => p.hasGps);
|
||||
}
|
||||
|
||||
// Sync filter chip visual state with currentFilter
|
||||
updateFilterChips();
|
||||
|
||||
const gallery = document.getElementById('gallery');
|
||||
gallery.innerHTML = filtered.map(p => {
|
||||
const idx = allPhotos.indexOf(p);
|
||||
@@ -843,17 +1150,29 @@ async function uploadSelected() {
|
||||
'<button class="btn btn-primary btn-xs" onclick="reprocessCurrent()">🔄 Re-run</button>' +
|
||||
'</div>';
|
||||
|
||||
// Manual entry when GPS exists but OCR failed
|
||||
if ((!ocr.match_5dash6 && !ocr.match_5plus) && data.exif && data.exif.gps) {
|
||||
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
|
||||
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">✏️ GPS found but no machine ID in OCR. Enter it manually:</div>' +
|
||||
'<div style="display:flex;gap:6px;">' +
|
||||
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
|
||||
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
|
||||
'</div>' +
|
||||
'<div id="manualResult" style="margin-top:4px;"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
// Store for manualLookup assign
|
||||
_lastPhotoId = data.photo_id;
|
||||
_lastPhotoGps = data.exif && data.exif.gps ? {lat: data.exif.gps.lat, lng: data.exif.gps.lng} : null;
|
||||
|
||||
// Always show manual entry field — user can correct OCR or enter ID manually
|
||||
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
|
||||
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">' +
|
||||
(ocr.match_5dash6 || ocr.match_5plus
|
||||
? '✏️ Try a different ID if the match above is wrong:'
|
||||
: '✏️ No machine ID found in OCR. Enter it manually:') +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;">' +
|
||||
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
|
||||
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
|
||||
'</div>' +
|
||||
'<div id="manualResult" style="margin-top:4px;"></div>' +
|
||||
'</div>';
|
||||
|
||||
// Reset button — always available to start fresh
|
||||
html += '<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);text-align:center;">' +
|
||||
'<button class="btn btn-outline btn-xs" onclick="resetAndReupload(' + (data.photo_id || 'null') + ')" style="color:var(--amber);border-color:var(--amber);">🔄 Reset & Re-process from scratch</button>' +
|
||||
'<div style="font-size:10px;color:var(--text3);margin-top:4px;">Clears DB entry and lets you re-analyze with manual entry</div>' +
|
||||
'</div>';
|
||||
|
||||
div.innerHTML = html;
|
||||
} catch (e) {
|
||||
@@ -876,6 +1195,12 @@ function manualLookup() {
|
||||
'<div class="asset-name">' + esc(a.name) + '</div>' +
|
||||
'<div class="asset-meta"><span>🆔 ' + esc(a.machine_id) + '</span><span>📦 ' + esc(a.category) + '</span>' +
|
||||
(a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;margin-top:6px;">' +
|
||||
'<button class="btn btn-primary btn-xs" style="width:auto;" onclick="assignManualId(' + JSON.stringify(a.machine_id) + ')">✅ Assign to Photo</button>' +
|
||||
(_lastPhotoGps
|
||||
? '<button class="btn btn-xs" style="width:auto;background:var(--green);color:#000;" onclick="pushGpsManual(' + a.id + ')">📤 Push GPS</button>'
|
||||
: '') +
|
||||
'</div></div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ No asset found for <strong>' + esc(val) + '</strong></span>';
|
||||
@@ -886,6 +1211,54 @@ function manualLookup() {
|
||||
});
|
||||
}
|
||||
|
||||
// Assign a machine ID to the last analyzed photo (manual entry)
|
||||
async function assignManualId(machineId) {
|
||||
if (!_lastPhotoId || !machineId) { alert('No photo loaded'); return; }
|
||||
const resultDiv = document.getElementById('manualResult');
|
||||
try {
|
||||
const r = await fetch('/api/assign-machine-id', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({photo_id: _lastPhotoId, machine_id: machineId})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.asset) {
|
||||
let extra = '';
|
||||
if (d.needs_gps && _lastPhotoGps) {
|
||||
extra = '<button class="btn btn-xs" style="margin-top:4px;background:var(--green);color:#000;width:auto;" onclick="pushGpsManual(' + d.asset.id + ')">📤 Push GPS</button>';
|
||||
}
|
||||
resultDiv.innerHTML = '<div style="font-size:12px;color:var(--green);">✅ Assigned: <strong>' + esc(d.asset.name) + '</strong> 🆔 ' + esc(d.asset.machine_id) + '</div>' + extra +
|
||||
'<div style="font-size:10px;color:var(--text3);margin-top:2px;">Saved to photo #' + _lastPhotoId + '</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ Could not assign: ' + esc(d.reason || 'unknown error') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Push GPS from the last analyzed photo to a canteen asset
|
||||
async function pushGpsManual(assetId) {
|
||||
if (!_lastPhotoGps || !assetId) { alert('No GPS data available'); return; }
|
||||
const resultDiv = document.getElementById('manualResult');
|
||||
try {
|
||||
const r = await fetch('/api/push-gps', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({asset_id: assetId, latitude: _lastPhotoGps.lat, longitude: _lastPhotoGps.lng})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.updated) {
|
||||
resultDiv.innerHTML = '<div style="font-size:12px;color:var(--green);">✅ GPS pushed successfully! 📍 ' + Number(_lastPhotoGps.lat).toFixed(5) + ', ' + Number(_lastPhotoGps.lng).toFixed(5) + '</div>'
|
||||
+ '<div style="font-size:10px;color:var(--text3);">Canteen DB updated</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ Push failed: ' + esc(d.reason || 'unknown') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocessCurrent() {
|
||||
const eng = document.getElementById('reRunEng').value;
|
||||
const model = document.getElementById('reRunModel').value;
|
||||
@@ -1054,6 +1427,11 @@ function resetAll() {
|
||||
allPhotos = [];
|
||||
selectedIdx = -1;
|
||||
currentFilter = 'all';
|
||||
// Reset filter chips to default state
|
||||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
||||
const allChip = Array.from(document.querySelectorAll('.filter-chip'))
|
||||
.find(c => c.textContent.trim() === 'All');
|
||||
if (allChip) allChip.classList.add('active');
|
||||
bulkData = [];
|
||||
if (bulkMap) { bulkMap.remove(); bulkMap = null; }
|
||||
document.getElementById('fileInput').value = '';
|
||||
@@ -1650,5 +2028,403 @@ if (navigator.serviceWorker) {
|
||||
// Initialize banner on load
|
||||
setTimeout(updateOfflineBanner, 500);
|
||||
</script>
|
||||
<!-- Lightbox — full-screen photo viewer with zoom -->
|
||||
<div id="lightbox" onclick="closeLightbox()">
|
||||
<button id="lightboxClose" onclick="event.stopPropagation();closeLightbox()">✕</button>
|
||||
<div id="lightboxContainer" onclick="event.stopPropagation()">
|
||||
<img id="lightboxImage" src="" alt="Full screen photo" draggable="false">
|
||||
</div>
|
||||
<div id="lightboxZoomHint">Pinch / scroll to zoom · Double-tap to fit</div>
|
||||
</div>
|
||||
<script>
|
||||
// Lightbox — zoomable full-screen photo viewer
|
||||
let _lbScale = 1, _lbMinScale = 1, _lbMaxScale = 6;
|
||||
let _lbTranslateX = 0, _lbTranslateY = 0;
|
||||
let _lbIsPanning = false, _lbStartX = 0, _lbStartY = 0;
|
||||
let _lbLastDist = 0, _lbLastTouchX = 0, _lbLastTouchY = 0;
|
||||
|
||||
function openLightbox(src) {
|
||||
const lb = document.getElementById('lightbox');
|
||||
const img = document.getElementById('lightboxImage');
|
||||
img.src = src;
|
||||
_resetLightbox();
|
||||
lb.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Show zoom hint briefly
|
||||
const hint = document.getElementById('lightboxZoomHint');
|
||||
hint.style.opacity = '1';
|
||||
setTimeout(() => { hint.style.opacity = '0'; }, 3000);
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
const lb = document.getElementById('lightbox');
|
||||
lb.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
document.getElementById('lightboxImage').src = '';
|
||||
}
|
||||
|
||||
function _resetLightbox() {
|
||||
_lbScale = 1; _lbTranslateX = 0; _lbTranslateY = 0;
|
||||
_lbIsPanning = false;
|
||||
const img = document.getElementById('lightboxImage');
|
||||
img.style.transform = '';
|
||||
img.classList.remove('zoomed');
|
||||
img.style.cursor = 'zoom-in';
|
||||
}
|
||||
|
||||
// --- Mouse wheel zoom ---
|
||||
document.getElementById('lightboxImage').addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
const rect = this.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
_zoomLightbox(this, mx, my, delta);
|
||||
}, { passive: false });
|
||||
|
||||
// --- Double-click/double-tap zoom toggle ---
|
||||
document.getElementById('lightboxImage').addEventListener('dblclick', function(e) {
|
||||
if (_lbScale > 1.2) {
|
||||
_resetLightbox();
|
||||
} else {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
_lbScale = 2.5;
|
||||
_lbTranslateX = -(mx * (_lbScale - 1)) / _lbScale;
|
||||
_lbTranslateY = -(my * (_lbScale - 1)) / _lbScale;
|
||||
_applyLightboxTransform(this);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Pan via mouse drag ---
|
||||
document.getElementById('lightboxImage').addEventListener('mousedown', function(e) {
|
||||
if (_lbScale <= 1) return;
|
||||
_lbIsPanning = true;
|
||||
_lbStartX = e.clientX - _lbTranslateX;
|
||||
_lbStartY = e.clientY - _lbTranslateY;
|
||||
this.style.cursor = 'grabbing';
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!_lbIsPanning) return;
|
||||
_lbTranslateX = e.clientX - _lbStartX;
|
||||
_lbTranslateY = e.clientY - _lbStartY;
|
||||
_applyLightboxTransform(document.getElementById('lightboxImage'));
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
_lbIsPanning = false;
|
||||
const img = document.getElementById('lightboxImage');
|
||||
if (img && _lbScale > 1) img.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
// --- Touch: pinch-zoom + pan ---
|
||||
document.getElementById('lightboxContainer').addEventListener('touchstart', function(e) {
|
||||
if (e.touches.length === 2) {
|
||||
_lbLastDist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
} else if (e.touches.length === 1) {
|
||||
_lbIsPanning = true;
|
||||
_lbStartX = e.touches[0].clientX - _lbTranslateX;
|
||||
_lbStartY = e.touches[0].clientY - _lbTranslateY;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.getElementById('lightboxContainer').addEventListener('touchmove', function(e) {
|
||||
const img = document.getElementById('lightboxImage');
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
const dist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
const delta = (dist - _lbLastDist) * 0.005;
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||
_zoomLightbox(img, cx - img.getBoundingClientRect().left, cy - img.getBoundingClientRect().top, delta);
|
||||
_lbLastDist = dist;
|
||||
} else if (e.touches.length === 1 && _lbIsPanning && _lbScale > 1) {
|
||||
_lbTranslateX = e.touches[0].clientX - _lbStartX;
|
||||
_lbTranslateY = e.touches[0].clientY - _lbStartY;
|
||||
_applyLightboxTransform(img);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById('lightboxContainer').addEventListener('touchend', function() {
|
||||
_lbIsPanning = false;
|
||||
});
|
||||
|
||||
// --- Swipe down to close ---
|
||||
let _lbSwipeStartY = 0;
|
||||
document.getElementById('lightboxContainer').addEventListener('touchstart', function(e) {
|
||||
if (e.touches.length === 1) _lbSwipeStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
document.getElementById('lightboxContainer').addEventListener('touchmove', function(e) {
|
||||
if (e.touches.length === 1 && _lbScale <= 1) {
|
||||
const dy = e.touches[0].clientY - _lbSwipeStartY;
|
||||
if (dy > 100) closeLightbox();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// --- Keyboard: Escape to close ---
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
});
|
||||
|
||||
function _zoomLightbox(img, mx, my, delta) {
|
||||
const newScale = Math.min(_lbMaxScale, Math.max(_lbMinScale, _lbScale + delta * _lbScale));
|
||||
if (newScale === _lbScale) return;
|
||||
const ratio = newScale / _lbScale;
|
||||
_lbTranslateX = mx - ratio * (mx - _lbTranslateX);
|
||||
_lbTranslateY = my - ratio * (my - _lbTranslateY);
|
||||
_lbScale = newScale;
|
||||
if (_lbScale <= 1) {
|
||||
_resetLightbox();
|
||||
} else {
|
||||
_applyLightboxTransform(img);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyLightboxTransform(img) {
|
||||
img.style.transform = 'translate(' + _lbTranslateX + 'px, ' + _lbTranslateY + 'px) scale(' + _lbScale + ')';
|
||||
img.classList.add('zoomed');
|
||||
img.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// EXIF Photos Map — all GPS-tagged photos plotted on Leaflet
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function loadExifMap() {
|
||||
document.getElementById('exifMapInfo').textContent = 'Loading...';
|
||||
fetch('/api/photos?limit=200')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const photos = data.photos || [];
|
||||
updateExifMapCounts(photos);
|
||||
renderExifMapPins(photos);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('exifMapInfo').textContent = '❌ Failed to load';
|
||||
console.warn('EXIF map load error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function updateExifMapCounts(photos) {
|
||||
const total = photos.length;
|
||||
const withGps = photos.filter(p => p.gps_lat && p.gps_lng).length;
|
||||
const withoutGps = total - withGps;
|
||||
document.getElementById('exifTotalPhotos').textContent = total;
|
||||
document.getElementById('exifGpsPhotos').textContent = withGps;
|
||||
document.getElementById('exifNoGpsPhotos').textContent = withoutGps;
|
||||
document.getElementById('exifMapCount').textContent = withGps + ' with GPS';
|
||||
}
|
||||
|
||||
function renderExifMapPins(photos) {
|
||||
// Get or init the map
|
||||
const container = document.getElementById('exifMapContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Destroy old map if exists
|
||||
if (exifMap) {
|
||||
exifMap.remove();
|
||||
exifMap = null;
|
||||
exifMarkers = [];
|
||||
_exifGpsPhotos = [];
|
||||
if (exifHeatLayer) {
|
||||
exifHeatLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const gpsPhotos = photos.filter(p => p.gps_lat && p.gps_lng);
|
||||
_exifGpsPhotos = gpsPhotos; // store globally for search
|
||||
if (gpsPhotos.length === 0) {
|
||||
document.getElementById('exifMapInfo').textContent = 'No GPS photos found — upload some!';
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:13px;">📍 No GPS-tagged photos yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Init map
|
||||
exifMap = L.map(container, { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap'
|
||||
}).addTo(exifMap);
|
||||
|
||||
// Add markers
|
||||
const bounds = [];
|
||||
const heatData = [];
|
||||
|
||||
gpsPhotos.forEach((p) => {
|
||||
const lat = Number(p.gps_lat);
|
||||
const lng = Number(p.gps_lng);
|
||||
if (isNaN(lat) || isNaN(lng)) return;
|
||||
|
||||
bounds.push([lat, lng]);
|
||||
heatData.push([lat, lng, 0.8]);
|
||||
|
||||
// Custom icon: colored circle with photo count
|
||||
const icon = L.divIcon({
|
||||
className: 'exif-marker-icon',
|
||||
html: '<div class="exif-marker-dot"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
popupAnchor: [0, -10],
|
||||
});
|
||||
|
||||
const marker = L.marker([lat, lng], { icon })
|
||||
.addTo(exifMap)
|
||||
.bindPopup(`
|
||||
<div style="min-width:180px;font-family:-apple-system,sans-serif;">
|
||||
<img src="/api/photos/${p.id}/file" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;margin-bottom:6px;" loading="lazy"
|
||||
onerror="this.style.display='none'">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:2px;">${esc(p.orig_filename || '')}</div>
|
||||
<div style="font-size:11px;color:#666;">📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
|
||||
<div style="font-size:10px;color:#999;margin-top:2px;">
|
||||
${p.machine_id ? '🆔 ' + esc(p.machine_id) : 'No machine ID'}
|
||||
${p.created_at ? ' · ' + new Date(p.created_at).toLocaleDateString() : ''}
|
||||
</div>
|
||||
${p.ocr_match_5dash6 ? '<div style="font-size:10px;color:#666;margin-top:2px;">🔍 OCR: ' + esc(p.ocr_match_5dash6) + '</div>' : ''}
|
||||
</div>
|
||||
`, { maxWidth: 260 });
|
||||
|
||||
marker._defaultIcon = icon;
|
||||
marker.machineId = (p.machine_id || '').toLowerCase();
|
||||
marker.photoData = p;
|
||||
exifMarkers.push(marker);
|
||||
});
|
||||
|
||||
// Fit bounds to show all markers
|
||||
if (bounds.length > 0) {
|
||||
const group = L.featureGroup(exifMarkers);
|
||||
exifMap.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
// Invalidate size after render
|
||||
setTimeout(() => {
|
||||
if (exifMap) exifMap.invalidateSize();
|
||||
}, 200);
|
||||
|
||||
document.getElementById('exifMapInfo').textContent = gpsPhotos.length + ' photo' + (gpsPhotos.length !== 1 ? 's' : '') + ' on map';
|
||||
|
||||
// Pre-compute heatmap data for toggle
|
||||
exifHeatLayer = L.heatLayer(heatData, {
|
||||
radius: 20, blur: 15, maxZoom: 17, max: 1.0, gradient: { 0.2: '#3b82f6', 0.5: '#22c55e', 0.8: '#f59e0b', 1.0: '#ef4444' }
|
||||
});
|
||||
if (document.getElementById('exifHeatmapToggle').checked) {
|
||||
exifHeatLayer.addTo(exifMap);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExifHeatmap() {
|
||||
if (!exifMap || !exifHeatLayer) return;
|
||||
if (document.getElementById('exifHeatmapToggle').checked) {
|
||||
exifMap.addLayer(exifHeatLayer);
|
||||
} else {
|
||||
exifMap.removeLayer(exifHeatLayer);
|
||||
}
|
||||
}
|
||||
|
||||
function openExifMapFullscreen() {
|
||||
if (!exifMap) {
|
||||
loadExifMap();
|
||||
// Scroll to map after a brief delay
|
||||
setTimeout(() => {
|
||||
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
|
||||
setTimeout(() => {
|
||||
if (exifMap) exifMap.invalidateSize();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// ── Machine ID search on EXIF Map ──
|
||||
|
||||
function searchExifMapByMachine() {
|
||||
const input = document.getElementById('exifMachineSearch');
|
||||
const status = document.getElementById('exifSearchStatus');
|
||||
const query = (input.value || '').trim().toLowerCase();
|
||||
if (!query) { status.textContent = '🔍 Enter a machine ID to search'; return; }
|
||||
if (!exifMap || exifMarkers.length === 0) {
|
||||
status.textContent = '⚠️ Map not loaded yet — click Refresh';
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore default icons on any previously highlighted markers
|
||||
clearExifHighlight();
|
||||
|
||||
// Find matching markers
|
||||
const matched = [];
|
||||
exifMarkers.forEach((marker) => {
|
||||
if (marker.machineId && marker.machineId.includes(query)) {
|
||||
matched.push(marker);
|
||||
}
|
||||
});
|
||||
|
||||
if (matched.length === 0) {
|
||||
status.textContent = '❌ No photos found with machine ID matching "' + esc(input.value) + '"';
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight matched markers with a larger, pulsing icon
|
||||
const highlightIcon = L.divIcon({
|
||||
className: 'exif-marker-icon exif-marker-search',
|
||||
html: '<div class="exif-marker-dot exif-marker-highlight"></div>',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
popupAnchor: [0, -14],
|
||||
});
|
||||
|
||||
const highlightGroup = L.featureGroup();
|
||||
matched.forEach(marker => {
|
||||
marker._searchHighlight = highlightIcon;
|
||||
marker.setIcon(highlightIcon);
|
||||
highlightGroup.addLayer(marker);
|
||||
});
|
||||
|
||||
window._exifSearchGroup = highlightGroup;
|
||||
exifMap.fitBounds(highlightGroup.getBounds().pad(0.3));
|
||||
|
||||
// Build status message
|
||||
let matchInfo = '🔎 Found <strong>' + matched.length + '</strong> photo' + (matched.length !== 1 ? 's' : '');
|
||||
const uniqueIds = [...new Set(matched.map(m => m.photoData.machine_id).filter(Boolean))];
|
||||
if (uniqueIds.length > 0) {
|
||||
matchInfo += ' • 🆔 ' + uniqueIds.map(id => '<code>' + esc(id) + '</code>').join(', ');
|
||||
}
|
||||
status.innerHTML = matchInfo;
|
||||
}
|
||||
|
||||
function clearExifHighlight() {
|
||||
if (!exifMarkers) return;
|
||||
exifMarkers.forEach(m => {
|
||||
if (m._searchHighlight) {
|
||||
m.setIcon(m._defaultIcon);
|
||||
m._searchHighlight = null;
|
||||
}
|
||||
});
|
||||
if (window._exifSearchGroup) {
|
||||
exifMap.removeLayer(window._exifSearchGroup);
|
||||
window._exifSearchGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearExifSearch() {
|
||||
document.getElementById('exifMachineSearch').value = '';
|
||||
document.getElementById('exifSearchStatus').textContent = '';
|
||||
clearExifHighlight();
|
||||
// Fit all markers back
|
||||
if (exifMap && exifMarkers.length > 0) {
|
||||
const group = L.featureGroup(exifMarkers);
|
||||
exifMap.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
}
|
||||
|
||||
// Also re-load EXIF map when new photos are processed (after loadPreviousPhotos completes)
|
||||
function refreshExifMap() {
|
||||
setTimeout(loadExifMap, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user