Compare commits

...

11 Commits

Author SHA1 Message Date
shawn ec7478b287 Add machine ID search bar to EXIF Photos Map with highlight zoom
- Search input + Find/Clear buttons above the map
- Partial match search against all marker machine_ids
- Matched markers get amber pulsing highlight icon (22px)
- Auto-zoom to fit matched markers
- Clear button restores original view
- Store _exifGpsPhotos globally for search
- Store machineId on each marker at creation time
- Store default icon reference for proper restore on clear
- CSS: animated amber pulse ring around highlighted markers
2026-05-26 17:41:39 -04:00
shawn bbc5b41868 feat: add GPS-only toggle on import screen
Adds a toggle switch in the summary bar to quickly hide non-GPS
photos from the gallery grid. More discoverable than filter chips.

- Toggle switch with clean CSS animation (sliding pill)
- Renders inline in summary grid next to GPS/no-GPS counts
- Overrides filter chips when active (GPS-only supremacy)
- Persists across photo additions (DOM state, not reset)
- Gallery, detail card, and upload functions all respect it
2026-05-25 21:24:15 -04:00
shawn e3d8481f4f Fix GPS filter: decouple chip state from event.target, sync on re-render
- Extracted updateFilterChips() helper to decouple chip visual state from
  the brittle event.target dependency in setFilter()
- Call updateFilterChips() inside renderGallery() so chip visual state
  always matches currentFilter when gallery re-renders
- Reset chip classes in resetAll() to prevent stale active state from
  persisting across sessions
- Fixes a bug where resetAll() set currentFilter='all' but left a
  previous chip (e.g. '📍 GPS') visually active
2026-05-25 21:23:04 -04:00
shawn 5488f579e7 fix: inline edit now displays correct asset location info on Enter
saveMachineIdInline used wrong field names when rendering the
location info div after a successful assignment:
  'location_name', 'building', 'zone' — none of which exist in
  the asset dict returned by lookup_machine_id().

Replaced with the correct fields matching lookupPrevAsset():
  building_name, floor, room, address, make, model

Now pressing Enter on an inline machine ID edit correctly shows
the asset's building, floor, room, address, and make/model.
2026-05-25 21:21:06 -04:00
shawn a243980c1b fix: inline edit now always looks up & shows asset info
Three changes:
1. renderPrevCard: always render prevAsset/prevMachineInfo divs
   (not conditional on GPS coordinates)
2. loadPreviousPhotos: call lookupPrevAsset for ALL photos with
   machine_id, not just those with GPS coords
3. saveMachineIdInline: dynamically create asset info divs on
   the card if they don't exist yet (defensive fallback)

Previously, manually entering a machine ID on a card without GPS
would silently succeed but never show the asset name/location
because the display elements only existed on GPS-equipped cards.
2026-05-25 21:20:16 -04:00
shawn 22c2acf0da fix: badge shows 'matched'/'no match' instead of machine ID 2026-05-25 21:14:38 -04:00
shawn 6c54912cfc feat: inline machine ID editing in Previous Photos
Tap any machine ID in the Previous Photos list to edit it inline.
- Replaces static text with editable input on click/tap
- Enter/blur saves via /api/assign-machine-id
- Esc restores original value
- Shows ✏️ indicator on hover
- Saves new ID to DB, refreshes asset info and badge instantly
2026-05-25 20:55:07 -04:00
shawn db45b386fa fix: reset flow now fetches photo before deleting, shows preview + manual entry
- resetPhoto() now downloads the photo blob before calling the reset endpoint,
  then re-shows it in the detail view with preview and manual entry
- resetAndReupload() same treatment, with fallback to server fetch
- Added Reset button to ALL server results (not just duplicates)
- Preview in Previous Photos cards with lightbox (prev-thumb + openLightbox)
2026-05-25 20:48:20 -04:00
shawn 23f479dd0c feat: add full-screen photo viewer with zoom + prev-photo thumbnails
Lightbox implementation was already present from prior commit
(04ac2fd). This commit adds the missing piece:

- Add thumbnail images to previously-processed photo cards
  rendered by renderPrevCard(), linked to /api/photos/{id}/file
- Clicking the thumbnail opens the full-screen lightbox with zoom
- Add .prev-thumb CSS for consistent square aspect-ratio display

Acceptance Criteria:
- Tap detail preview image -> full-screen overlay (already done)
- Photo fills screen with proper aspect ratio (already done)
- Pinch/scroll zoom + double-tap toggle (already done)
- Close button + swipe-down-to-dismiss (already done)
- Also works for previous photos section thumbnails (NOW DONE)
2026-05-25 20:38:50 -04:00
shawn 04ac2fd7c7 fix: show manual entry when OCR digits found but lookup fails
- Always show manual entry field regardless of OCR result or GPS presence
- Context-sensitive label: different text when OCR found digits vs none
- Add Assign button to manual lookup result (POST /api/assign-machine-id)
- Add Push GPS button when GPS available after assign
- Store _lastPhotoId / _lastPhotoGps for manual entry functions
- Add lightbox: full-screen photo viewer with pinch/scroll/double-tap zoom
2026-05-25 20:35:45 -04:00
shawn 9512819e3e Add AGENTS.md for AI agent context 2026-05-25 20:18:58 -04:00
2 changed files with 877 additions and 32 deletions
+69
View File
@@ -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
View File
@@ -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: '&copy; 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>