630 lines
25 KiB
Markdown
630 lines
25 KiB
Markdown
# EXIF Scanner — Feature Roadmap
|
||
|
||
> Living feature specification document. Covers all proposed features for the photo
|
||
> upload / OCR / GPS-push app at `~/projects/exif-test/`.
|
||
|
||
**Legend:** ✅ done · 💡 proposed
|
||
|
||
---
|
||
|
||
## 1. ✅ Offline Queue — Service Worker + IndexedDB
|
||
|
||
### Use Case
|
||
|
||
Facility audits happen in warehouses, basements, parking lots, and rural sites — all
|
||
places with unreliable or zero cellular signal. Currently if the server is unreachable
|
||
the app shows nothing useful and queued photos are lost on page refresh. The user needs
|
||
to "fire and forget": snap photos, move to the next machine, and have the uploads drain
|
||
silently when signal returns.
|
||
|
||
### User Experience
|
||
|
||
1. Open the app — service worker installs, page loads from cache instantly
|
||
2. If online: everything works as today (live upload via fetch to `/api/analyze`)
|
||
3. If offline (navigator.onLine === false or fetch fails):
|
||
- Photo thumbnails still generate (client-side EXIF via exifr.js works offline)
|
||
- The gallery, summary, filter chips all work from IndexedDB-local
|
||
- "Bulk Process" button changes to "📡 Queue for Upload (N pending)"
|
||
- A small banner at the top: "📡 Offline — N photos queued"
|
||
4. When signal returns (online event or periodic background sync):
|
||
- Queue drains FIFO, one at a time, with retry (3 attempts, exponential backoff)
|
||
- Banner updates: "📡 Syncing… (3/12 remaining)"
|
||
- On completion: banner disappears, results load from server `/api/photos`
|
||
5. Page refresh mid-queue: IndexedDB persists the queue, resumes on next load
|
||
|
||
### Spec
|
||
|
||
```
|
||
IndexedDB schema — "photos-queue" store:
|
||
{ id: auto_inc, file_blob: Blob, filename: string, timestamp: number,
|
||
retries: number, status: 'queued'|'uploading'|'done'|'failed' }
|
||
|
||
Service worker:
|
||
- Cache-first strategy for static assets (/, /static/*)
|
||
- Network-first for /api/* (falls back to cached error page)
|
||
- Listen for 'sync' events from periodicSync API if available
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **Service Worker registration** in `<script>` at page top
|
||
- **IndexedDB** via `idb-keyval` or raw `indexedDB` API (no extra dep)
|
||
- **Background Sync API** (`navigator.serviceWorker.ready.then(r => r.sync.register('sync-photos'))`) — falls back to `online` event listener
|
||
- **Queue manager** — `class PhotoQueue` in the main JS, not in SW (SW can't read blobs efficiently)
|
||
- **Retry** — exponential backoff: 2s, 5s, 15s, then mark failed. Failed queue items show "❌ Retry" button in UI.
|
||
|
||
### Files Touched
|
||
|
||
- `static/index.html` — add `<script>` for sw registration, queue manager init
|
||
- `static/sw.js` — new file, ~40 lines
|
||
- `static/index.html` — add offline banner and queue status UI (~50 lines)
|
||
- `static/index.html` — modify `uploadSelected()` and `startBulkProcess()` to check `navigator.onLine` and route to queue if offline
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Load app while offline | Page renders from cache, banner shows "Offline" |
|
||
| 2 | Select photos while offline | Gallery thumbnails appear, summary counts work |
|
||
| 3 | Click "Bulk Process" while offline | Photos go to IndexedDB queue, banner shows count |
|
||
| 4 | Go online while queue has items | Queue drains, results appear once all uploaded |
|
||
| 5 | Refresh mid-queue | Queue persists, resumes draining on reload |
|
||
| 6 | Server returns 500 during drain | Retry 3 times, then mark failed with "❌" |
|
||
| 7 | Select 100 photos while offline | All queued, memory usage stays reasonable (Blob refs) |
|
||
| 8 | Delete queue item | Removed from IndexedDB, count decrements |
|
||
|
||
---
|
||
|
||
## 4. ✅ Export — CSV / KML / Copy Coordinates
|
||
|
||
### Use Case
|
||
|
||
After a walk-around audit, the collected data (photo GPS, OCR'd machine IDs, matched
|
||
asset names) needs to get into spreadsheets for facility management, Google Earth for
|
||
visual review, or a clipboard for pasting into an internal tool. Currently the data is
|
||
trapped in the photos.db SQLite on the server.
|
||
|
||
### User Experience
|
||
|
||
1. After any bulk process, a new **Export** button appears in the bulk section header
|
||
2. Tap Export → modal/action sheet shows three options:
|
||
- **📊 CSV** — downloads `exif-export-YYYY-MM-DD.csv`
|
||
- **🌍 KML** — downloads `exif-export-YYYY-MM-DD.kml` (open in Google Earth)
|
||
- **📋 Copy all GPS** — copies `lat,lng` pairs to clipboard
|
||
3. Also available from the **Previously Processed** section header
|
||
4. CSV columns: `filename, lat, lng, machine_id, asset_name, building, floor, room, make, model, ocr_engine, sticker_color, timestamp`
|
||
5. KML: each photo is a Placemark with name=asset name (or filename), coords from GPS, description with all metadata
|
||
|
||
### Spec
|
||
|
||
```
|
||
GET /api/export?format=csv&session_id=optional
|
||
GET /api/export?format=kml&session_id=optional
|
||
GET /api/export?format=clipboard&session_id=optional # returns plain text lat,lng pairs
|
||
|
||
Defaults to all photos if no session_id. Supports ?limit=500 for large exports.
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **CSV**: FastAPI `StreamingResponse` with `csv.writer` — streams line by line, no memory spike
|
||
- **KML**: Build XML manually (simple template, no library needed). Validates against KML 2.2 schema.
|
||
- **Clipboard**: Frontend-only — parse server response, use `navigator.clipboard.writeText()`
|
||
- **Character encoding**: All text fields UTF-8, CSV with BOM for Excel compatibility
|
||
|
||
### Files Touched
|
||
|
||
- `server.py` — add `/api/export` endpoint (~60 lines)
|
||
- `static/index.html` — add export button + modal, clipboard handler (~40 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Export CSV from bulk results | Downloads valid CSV, opens in Excel without encoding issues |
|
||
| 2 | Export KML from bulk results | Downloads KML, opens in Google Earth, pins show at correct coords |
|
||
| 3 | Copy GPS from prev photos | Clipboard contains `lat,lng` pairs, one per line |
|
||
| 4 | Export with 0 results | Returns empty CSV with headers only |
|
||
| 5 | Export 200 photos | Streams correctly, no timeout |
|
||
| 6 | CSV column count | All 11 columns present, no missing data |
|
||
|
||
---
|
||
|
||
## 5. ✅ Inline Machine ID Edit
|
||
|
||
### Use Case
|
||
|
||
OCR is not perfect — stickers get dirty, bent, faded, or partially covered. A human
|
||
can still read "12345" from the sticker even when OCR returns gibberish. Currently
|
||
the only way to assign the correct machine ID is to re-upload the photo with a
|
||
different engine/model, or use the manual entry field in the detail view. Neither
|
||
works in bulk mode. The user needs to tap a failed OCR result and type the correct
|
||
ID inline, then push GPS immediately.
|
||
|
||
### User Experience
|
||
|
||
1. In bulk results, a card with **"No OCR match"** is tappable
|
||
2. Tap the label → it turns into a text `<input>` pre-filled with any partial digits
|
||
3. Type the machine ID (e.g. `12345-678901` or just `12345`)
|
||
4. Press Enter or tap away → app calls `/api/lookup?machine_id=...`
|
||
5. If found: card updates to show the asset name, and a **Push GPS** button appears
|
||
6. If not found: shows "⚠️ No asset match" — user can try again
|
||
7. Works identically in the **Previously Processed** section
|
||
|
||
### Spec
|
||
|
||
```
|
||
On card: <span class="ocr-edit" data-idx="N" data-photoid="PHOTO_ID">
|
||
No OCR match
|
||
</span>
|
||
|
||
On click → replace with:
|
||
<input class="ocr-input" data-idx="N" data-photoid="PHOTO_ID"
|
||
placeholder="Type machine ID…" value="">
|
||
|
||
On blur/Enter → POST /api/assign-machine-id with {photo_id, machine_id}
|
||
→ updates photos DB → returns {asset, needs_gps}
|
||
→ re-renders bulk card
|
||
```
|
||
|
||
### Tech
|
||
|
||
- No backend changes needed for lookup (already has `/api/lookup`)
|
||
- New endpoint `POST /api/assign-machine-id` to persist the override to `photos.db` (sets `machine_id` field)
|
||
- Frontend: event delegation on bulk cards for the tap handler
|
||
- Debounced lookup (300ms) to avoid hammering the API while typing
|
||
|
||
### Files Touched
|
||
|
||
- `server.py` — add `POST /api/assign-machine-id` (~20 lines)
|
||
- `static/index.html` — modify `renderBulkResults()` to make OCR labels editable (~40 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Tap "No OCR match" | Label becomes editable input |
|
||
| 2 | Type valid machine ID | Asset loads, card shows asset details, Push GPS appears |
|
||
| 3 | Type invalid machine ID | "⚠️ No asset match" shown, input stays editable |
|
||
| 4 | Type partial ID (5 digits) | Lookup by first 5 digits works |
|
||
| 5 | Clear input and blur | Input reverts to "No OCR match" |
|
||
| 6 | Assign ID on photo that already has GPS | Push GPS button works immediately |
|
||
| 7 | Assign ID when GPS missing | Card shows "No GPS — upload new photo" |
|
||
|
||
---
|
||
|
||
## 6. ✅ GPS Proximity Comparison
|
||
|
||
### Use Case
|
||
|
||
When a photo has GPS coordinates, there may be canteen assets nearby that you
|
||
haven't photographed yet — or you may have photographed the same machine twice
|
||
from different angles. Knowing what's within, say, 10 meters helps the auditor
|
||
decide: "did I already get that one?" or "there's another machine right there."
|
||
|
||
Also useful for discovering machines whose DB address is wrong (GPS says
|
||
they're 200m from their listed building).
|
||
|
||
### User Experience
|
||
|
||
1. In bulk results, each card with GPS shows a new line:
|
||
**"📍 Nearby: 2 assets within 10m"**
|
||
2. Tap the line → expands a list of nearby machines:
|
||
- `🆔 12345 · Ice Machine 300 · 8m away · 📶 has GPS` or
|
||
- `🆔 67890 · Soda Dispenser · 12m away ⚠️ no GPS yet`
|
||
3. The proximity badge color indicates:
|
||
- 🟢 Green: 0-5m (probable same machine)
|
||
- 🟡 Amber: 5-20m (nearby machines to check)
|
||
- 🔴 Red: >20m but <50m (far neighbor)
|
||
4. Also shown on the bulk map: proximity rings (10m circles) around each new photo pin
|
||
|
||
### Spec
|
||
|
||
```
|
||
GET /api/nearby?lat=<float>&lng=<float>&radius=<meters>&exclude_photo_id=<optional>
|
||
|
||
Returns:
|
||
{ nearby: [
|
||
{ asset_id, machine_id, name, distance_m, latitude, longitude,
|
||
has_gps: bool, building, floor, room }
|
||
],
|
||
count: N,
|
||
radius_m: 10 }
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **Spatial query**: SQLite with `(lat - ?)^2 + (lng - ?)^2 < (rad_deg)^2` approximation.
|
||
At small radii (<1km), the Euclidean approximation over lat/lng is accurate enough.
|
||
Formula: `1° lat ≈ 111,320m`, `1° lng ≈ 111,320 * cos(lat)` at given latitude.
|
||
- **Radius default**: 10m, configurable via query param
|
||
- **Exclusion**: pass `exclude_photo_id` to avoid self-matches (the photo's own asset)
|
||
- **Frontend**: Leaflet circle markers + expandable inline list in bulk cards
|
||
|
||
### Files Touched
|
||
|
||
- `server.py` — add `GET /api/nearby` endpoint (~30 lines with spatial math)
|
||
- `static/index.html` — modify `renderBulkResults()` to fetch and display nearby data (~60 lines)
|
||
- `static/index.html` — add Leaflet circle overlays in `renderBulkResults()` (~10 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Photo GPS at (28.5, -81.5), asset at (28.5001, -81.5001) | Nearby returns asset, distance ~12m |
|
||
| 2 | No assets within 10m | `count: 0`, card shows "No nearby assets" |
|
||
| 3 | Asset at exactly 10.5m | Not returned (radius is <10m, not <=) |
|
||
| 4 | Photo has no GPS | Nearby line hidden |
|
||
| 5 | 5 assets within 10m | All returned, sorted by distance ascending |
|
||
| 6 | Nearby includes asset already photographed | Excluded by `exclude_photo_id` |
|
||
| 7 | Spam-tap the nearby label | Only one request fires (debounce) |
|
||
|
||
---
|
||
|
||
## 7. ✅ Walk-Path Timeline
|
||
|
||
### Use Case
|
||
|
||
After a session, the auditor needs to see their actual route through the building
|
||
to spot coverage gaps. A polyline connecting photo locations in chronological order
|
||
reveals skipped aisles, missed corners, or areas where photos were taken out of
|
||
walking order (suggesting you backtracked because you missed something).
|
||
|
||
### User Experience
|
||
|
||
1. After bulk processing, a toggle switch above the map: **🗺️ Show Walk Path**
|
||
2. On: the map draws a connected polyline between photo pins in upload order,
|
||
with numbered markers (① → ② → ③ …) showing the sequence
|
||
3. Each segment is color-coded by time gap:
|
||
- 🟢 Green: <30s between photos (natural pace)
|
||
- 🟡 Amber: 30s–2m (stopped to look around)
|
||
- 🔴 Red: >2m (took a break or got distracted)
|
||
4. Hover/tap a segment → tooltip: "Walked 15m from Machine A to Machine B in 45s"
|
||
5. Off the map, a timeline list shows each photo in order with distance from previous
|
||
|
||
### Spec
|
||
|
||
```
|
||
No new backend endpoint. The bulk results already include filename, exif.gps, and
|
||
we can derive "upload order" from the response array order. Frontend-only feature.
|
||
|
||
Timeline data shape (derived client-side):
|
||
[{ step: 1, lat, lng, name, filename, time_gap_s, distance_m }]
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **Polyline**: Leaflet `L.polyline(latlngs, {color, weight, opacity})` with gradient
|
||
segments using `L.polylineDecorator` or manual segment drawing
|
||
- **Segment coloring**: Draw N-1 polylines between consecutive points, each with its
|
||
own color based on `time_gap`
|
||
- **Distance calc**: Haversine formula in JS between consecutive GPS coords
|
||
- **Time gap**: From client-side `Date.now()` at photo selection time (stored in
|
||
`allPhotos[]` array)
|
||
|
||
### Files Touched
|
||
|
||
- `static/index.html` — add walk-path toggle + polyline drawing in `renderBulkResults()` (~60 lines)
|
||
- `static/index.html` — store selection timestamps in `allPhotos[]` (~5 lines)
|
||
- `static/index.html` — add Leaflet polyline-decorator plugin (CDN) (~1 line)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | 5 photos in a straight line | Polyline connects ①→②→③→④→⑤ in order |
|
||
| 2 | Photos taken 10s apart | All segments green |
|
||
| 3 | One 5-minute gap between photo 3 and 4 | Segment between them is red |
|
||
| 4 | Toggle off → on | Polyline clears and redraws |
|
||
| 5 | Only 1 photo has GPS | No polyline drawn, toggle hidden |
|
||
| 6 | Consecutive photos 500m apart | Segment shows correct distance in tooltip |
|
||
| 7 | Timeline list scrollable with 50 entries | Virtual scroll works smoothly |
|
||
|
||
---
|
||
|
||
## 8. 💡 Batch Machine ID Assign
|
||
|
||
### Use Case
|
||
|
||
Large equipment (walk-in coolers, industrial washers, HVAC units) may need 3-5 photos
|
||
from different angles to properly document. Currently each photo gets its own card,
|
||
processed independently. The auditor needs to say "these 4 photos are all of the same
|
||
machine" and push GPS once for all of them.
|
||
|
||
Also useful when a machine ID sticker appears in multiple shots (e.g. zoomed out +
|
||
close up) but only one photo has a readable barcode. The auditor can set the machine
|
||
ID once and apply it to all linked photos.
|
||
|
||
### User Experience
|
||
|
||
1. In bulk results, each card gets a checkbox on the left (hidden by default)
|
||
2. Tap **📎 Batch Select** button in the header → checkboxes appear on all cards
|
||
3. Check 2+ cards → a floating action bar appears:
|
||
**"📎 3 selected → Assign all to Machine ID: [________] [🔍 Lookup] [📤 Push GPS]"**
|
||
4. Type a machine ID, tap Lookup → confirms the asset name
|
||
5. Tap **Push GPS** → pushes GPS from every selected photo to that asset
|
||
(only photos that have GPS; ones without are listed as "no GPS available")
|
||
6. Selected cards get a batch icon and link to the same asset
|
||
|
||
### Spec
|
||
|
||
```
|
||
POST /api/bulk-assign
|
||
Body: { photo_ids: [int], machine_id: str }
|
||
→ Updates photos.machine_id for each photo in DB
|
||
→ If any photos have GPS and asset has NULL lat/lng, optionally push GPS
|
||
|
||
Returns:
|
||
{ updated: N, gps_pushed: M, no_gps: [...filenames] }
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **New endpoint**: `POST /api/bulk-assign` — similar to `assign-machine-id` but array
|
||
- **Frontend**: checkbox state array, floating action bar with `position: sticky; bottom: 0`
|
||
- **No extra dependencies**: pure CSS for sticky bar, vanilla JS for selection state
|
||
|
||
### Files Touched
|
||
|
||
- `server.py` — add `POST /api/bulk-assign` (~30 lines)
|
||
- `static/index.html` — add checkbox mode, selection state, floating bar (~80 lines)
|
||
- `static/index.html` — modify `renderBulkResults()` to render checkboxes when in batch mode
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Select 3 photos, assign machine ID | All 3 photos show the matched asset |
|
||
| 2 | Select 1 photo only | Floating bar shows "Select 2+ photos" |
|
||
| 3 | Assign with all photos having GPS | GPS pushed to asset, button shows "✓ Pushed!" |
|
||
| 4 | Assign with 2 photos having GPS, 1 without | GPS pushed, warning lists the one without |
|
||
| 5 | Deselect all | Floating bar disappears |
|
||
| 6 | Refresh page after batch assign | Prev photos section shows the assignment |
|
||
|
||
---
|
||
|
||
## 9. ✅ Session Management
|
||
|
||
### Use Case
|
||
|
||
An auditor does weekly walk-throughs of different buildings. Each session (a batch
|
||
of photos taken at a specific time/place) should be a first-class object: named,
|
||
tagged with a building/floor, and reviewable later. Currently all photos go into
|
||
a flat `photos` table with no grouping.
|
||
|
||
Without sessions, answering "when was Building B, Floor 2 last audited?" requires
|
||
scanning all photo timestamps and cross-referencing GPS against building boundaries.
|
||
|
||
### User Experience
|
||
|
||
1. Before starting a bulk process, the app shows: **"Session: [📂 New Session ▼]"**
|
||
2. Tap it → name the session (e.g. "Building B Floor 2 — May 25")
|
||
3. Or tap **📋** → pick from recent buildings/floors in the DB:
|
||
`Building A Floor 1` · `Building A Floor 2` · `Building B Floor 1` …
|
||
4. After processing, the session shows in a new **Sessions** section above "Previously Processed"
|
||
5. Each session card:
|
||
- **"Building B Floor 2 — May 25"**
|
||
- `📍 23 photos · 18 matched · 2 need GPS · Last: 10m ago`
|
||
- Tap → expands to show all photos from that session
|
||
- Share button → export CSV/KML for just this session
|
||
- Calendar icon → shows on calendar view
|
||
6. **Calendar view**: toggle to see sessions on a calendar grid. Green = full coverage,
|
||
amber = partial, red = no data. Tap a date → that session's results.
|
||
|
||
### Spec
|
||
|
||
```
|
||
New DB table: sessions
|
||
id INTEGER PRIMARY KEY
|
||
name TEXT
|
||
building TEXT
|
||
floor TEXT
|
||
photo_count INTEGER
|
||
matched_count INTEGER
|
||
needs_gps_count INTEGER
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
|
||
photos table: add session_id INTEGER REFERENCES sessions(id)
|
||
|
||
GET /api/sessions
|
||
→ [{ id, name, building, floor, photo_count, matched_count, needs_gps_count, created_at }]
|
||
|
||
GET /api/sessions/{id}
|
||
→ session detail + all photos in it
|
||
|
||
POST /api/sessions
|
||
Body: { name?, building?, floor? }
|
||
→ creates session, returns { id }
|
||
|
||
POST /api/sessions/{id}/close
|
||
→ finalizes session (no more photos can be added)
|
||
```
|
||
|
||
### Migration
|
||
|
||
```sql
|
||
ALTER TABLE photos ADD COLUMN session_id INTEGER REFERENCES sessions(id);
|
||
```
|
||
|
||
No data loss — existing photos get `session_id = NULL` and appear in "Unassigned"
|
||
section.
|
||
|
||
### Files Touched
|
||
|
||
- `server.py` — add sessions table, CRUD endpoints, migration (~80 lines)
|
||
- `server.py` — modify `/api/bulk-process` to accept `session_id` param (~10 lines)
|
||
- `static/index.html` — add session picker UI, sessions section, calendar view (~120 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Create new session, process 5 photos | Session shows in list with count 5 |
|
||
| 2 | Process photos without a session | Go to "Unassigned" section (backward compatible) |
|
||
| 3 | Open session detail | Shows all photos with map |
|
||
| 4 | Calendar view with 3 sessions on different dates | Green cells on correct dates |
|
||
| 5 | Export session as CSV | Only that session's photos in the CSV |
|
||
| 6 | Delete session | All photos moved to "Unassigned" |
|
||
|
||
---
|
||
|
||
## 10. ✅ GPS Drift Check
|
||
|
||
### Use Case
|
||
|
||
The canteen DB stores an asset's expected location (from its address/building). When
|
||
a photo's GPS is far from that expected location, something is wrong:
|
||
- The photo is of a **different machine** than the sticker suggests (wrong sticker on site?)
|
||
- The **asset was moved** (machines get relocated without updating the DB)
|
||
- **GPS drift** (tall buildings, narrow corridors cause coordinate noise up to 30m)
|
||
- The **asset's address is wrong** in the DB
|
||
|
||
A flag alerts the auditor to investigate rather than blindly pushing GPS data.
|
||
|
||
### User Experience
|
||
|
||
1. In bulk results, a matched asset shows a new badge next to its name:
|
||
- 🟢 **GPS OK**: photo within 10m of asset's DB coordinates
|
||
- 🟡 **⚠️ Drift 30m**: photo is 30m from asset's expected location
|
||
- 🔴 **⚠️⚠️ Drift 200m**: photo is 200m away — probable mismatch
|
||
2. Tap the drift badge → explanation tooltip:
|
||
"Photo GPS is 30m from this asset's DB location (28.5383, -81.3794).
|
||
Possible: GPS drift, asset was moved, or wrong sticker."
|
||
3. If asset has no DB coordinates → no drift check (show "📍 No DB coords to compare")
|
||
4. On the map: a line connects the photo pin to the asset's DB pin with the distance
|
||
labeled. Color = drift severity.
|
||
|
||
### Spec
|
||
|
||
```
|
||
No new endpoint. Drift is calculated client-side from existing data:
|
||
- Photo GPS: from exif.gps (already in bulk result)
|
||
- Asset DB coords: from asset.latitude/asset.longitude (already in bulk result)
|
||
- Distance: Haversine calculation in JS
|
||
|
||
Severity thresholds:
|
||
< 10m → green "GPS OK"
|
||
10-50m → amber "⚠️ Drift Nm"
|
||
> 50m → red "⚠️⚠️ Drift Nm"
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **Haversine**: copy the existing Python Haversine or use a small JS library
|
||
- **Rendering**: modify `renderBulkResults()` card template to include drift badge
|
||
- **Map line**: Leaflet `L.polyline` between photo pin and asset DB pin (dashed, color = severity)
|
||
- **No backend changes**: all data already available in the bulk response
|
||
|
||
### Files Touched
|
||
|
||
- `static/index.html` — add haversine function (~10 lines)
|
||
- `static/index.html` — modify `renderBulkResults()` to render drift badges (~30 lines)
|
||
- `static/index.html` — add dashed connecting lines on map (~15 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | Photo GPS 5m from asset DB coords | 🟢 GPS OK badge |
|
||
| 2 | Photo GPS 25m from asset DB coords | 🟡 ⚠️ Drift 25m badge, amber line on map |
|
||
| 3 | Photo GPS 100m from asset DB coords | 🔴 ⚠️⚠️ Drift 100m badge, red line on map |
|
||
| 4 | Asset has NULL lat/lng in DB | "📍 No DB coords to compare" shown |
|
||
| 5 | No asset match (OCR failed) | No drift check shown |
|
||
| 6 | Drift line on map matches compass direction | Line goes from photo pin toward asset DB pin |
|
||
|
||
---
|
||
|
||
## 11. ✅ Coverage Heatmap
|
||
|
||
### Use Case
|
||
|
||
After auditing a large floor, the auditor needs to know *did I miss anything?*
|
||
A heatmap overlay on the map shows photo density as a color gradient:
|
||
- **Red/hot** areas = many photos taken (well covered)
|
||
- **Blue/cold** areas = few or no photos (potential gaps)
|
||
- **White** = no data
|
||
|
||
Combined with the walk-path timeline, this is the fastest way to spot skipped aisles,
|
||
unvisited rooms, or areas where you didn't take enough establishing shots.
|
||
|
||
### User Experience
|
||
|
||
1. In the bulk results map, a toggle **🔥 Heatmap** (alongside "🗺️ Show Walk Path")
|
||
2. On: a smooth color gradient overlay appears on the map
|
||
3. The heatmap is computed from all photo GPS points + all asset DB locations
|
||
4. Adjustable **intensity** slider: low → shows broad coverage patterns;
|
||
high → shows tight clusters (multiple photos of the same machine)
|
||
5. **Radius** slider: how far each photo's "influence" extends (default 15m)
|
||
6. Tap a hot spot → shows how many photos contributed to that area
|
||
|
||
### Spec
|
||
|
||
```
|
||
Frontend-only. Uses leaflet-heat (https://github.com/Leaflet/Leaflet.heat).
|
||
|
||
Input data:
|
||
- Photo GPS coordinates (from bulk results or prev photos)
|
||
- Asset DB coordinates (from matched assets)
|
||
|
||
Heatmap options:
|
||
- radius: 15 (default), adjustable 5-50
|
||
- blur: 15 (default)
|
||
- maxZoom: 18
|
||
- gradient: { 0.0: 'blue', 0.3: 'cyan', 0.5: 'lime', 0.8: 'yellow', 1.0: 'red' }
|
||
```
|
||
|
||
### Tech
|
||
|
||
- **Leaflet.heat**: CDN script tag — lightweight (~10KB), no build step
|
||
- **Data preparation**: Extract `[lat, lng, intensity]` tuples where intensity is
|
||
`1.0` for each photo (or higher if multiple photos at the same point)
|
||
- **Asset overlay**: Optionally include asset DB locations at 0.5 intensity to show
|
||
where machines exist but have no photos yet (double-check areas)
|
||
- **Sliders**: HTML `<input type="range">` for radius and intensity
|
||
|
||
### Files Touched
|
||
|
||
- `static/index.html` — add Leaflet.heat CDN script (~1 line)
|
||
- `static/index.html` — add heatmap toggle + sliders (~20 lines)
|
||
- `static/index.html` — add heatmap layer control in `renderBulkResults()` and `loadPreviousPhotos()` (~30 lines)
|
||
|
||
### Test Cases
|
||
|
||
| # | Scenario | Expected |
|
||
|---|----------|----------|
|
||
| 1 | 10 photos clustered within 20m | Red hotspot at that area |
|
||
| 2 | 2 photos 100m apart | Two separate blue spots |
|
||
| 3 | Toggle heatmap on → off → on | Clears and redraws correctly |
|
||
| 4 | Adjust radius from 15 to 50 | Hotspots get larger/smoother |
|
||
| 5 | 50 photos spread across a 200m floor | Gradient shows red in high-density areas, blue in gaps |
|
||
| 6 | Leaflet.heat not loaded (network error) | Toggle hidden, no errors in console |
|
||
|
||
---
|
||
|
||
## Appendix: Implementation Order
|
||
|
||
### Phase 1 (Current) — Core UX fixes
|
||
- Fix API key loading ✅
|
||
- Add reset endpoint ✅
|
||
- Improve maps ✅
|
||
|
||
### Phase 2 — High-impact additions
|
||
1. [x] Inline Machine ID Edit (#5) — smallest scope, biggest daily-ux gain
|
||
2. [x] GPS Proximity (#6) — catches missed machines
|
||
3. [x] GPS Drift Check (#10) — data quality
|
||
4. [x] Walk-Path Timeline (#7) — visual route validation
|
||
|
||
### Phase 3 — Offline + Export + Sessions
|
||
5. [x] Export (#4) — gets data out of the app
|
||
6. [x] Offline Queue (#1) — works anywhere
|
||
7. [x] Session Management (#9) — historical tracking
|
||
|
||
### Phase 4 — Power-user features
|
||
8. [x] Batch Machine ID Assign (#8) — speed for multi-photo equipment
|
||
9. [x] Coverage Heatmap (#11) — completeness guarantee
|
||
|
||
---
|
||
|
||
*Last updated: 2026-05-25*
|
||
*Proposed by: Hermes Agent / Shawn*
|