From 7cf546af7aec1cd22ad0197a2f211347e37dd316 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Apr 2026 23:44:29 +0200 Subject: [PATCH] feat: map view with GPS extraction fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new Map sidebar entry that plots photos by their EXIF GPS coordinates on a clustered Leaflet map. While wiring this up, the metadata extractor was reading unprefixed GPS keys that never exist in `exiftool -G -j` output AND assumed coordinates were already floats — every photo silently lost its GPS. The new extract_gps helper handles Composite/EXIF group prefixes and parses DMS strings, and lat/lon are stored as first-class indexed columns so the map can query them without parsing exif_json on every request. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/database.py | 25 +++ backend/app/models/photos.py | 10 +- backend/app/routers/library.py | 25 ++- backend/app/routers/photos.py | 30 ++++ backend/app/schemas/photos.py | 2 + backend/app/services/metadata.py | 155 ++++++++++++---- backend/app/tasks/scan.py | 34 +++- frontend/package-lock.json | 25 +++ frontend/package.json | 1 + frontend/src/App.tsx | 17 +- .../src/components/layout/LeftSidebar.tsx | 5 + frontend/src/components/map/MapView.tsx | 169 ++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 21 ++- .../src/components/sidebar/PhotoInfoPanel.tsx | 26 ++- frontend/src/main.tsx | 4 + frontend/src/services/api.ts | 15 ++ frontend/src/types/photo.ts | 11 ++ 17 files changed, 529 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/map/MapView.tsx diff --git a/backend/app/database.py b/backend/app/database.py index d3f4e12..2f4261b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -91,11 +91,18 @@ async def init_db(): "duplicate_group_id", "ALTER TABLE photos ADD COLUMN duplicate_group_id VARCHAR", ), + ("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"), + ("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"), ] + # Track whether the GPS columns were just added so we can kick + # off a one-shot backfill of existing photos at the end of init. + gps_columns_added = False for col_name, alter_sql in pending_alters: if col_name not in existing_cols: logger.info(f"Adding photos.{col_name} column") await conn.execute(text(alter_sql)) + if col_name in ("latitude", "longitude"): + gps_columns_added = True # Indexes for the new duplicate-detection columns. CREATE INDEX # IF NOT EXISTS is supported on SQLite so this is safe to run # every startup. @@ -108,9 +115,27 @@ async def init_db(): "ON photos(duplicate_group_id)" ) ) + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_photos_lat_lon " + "ON photos(latitude, longitude)" + ) + ) logger.info("Database initialized successfully") + # If we just introduced the GPS columns on an existing install, kick + # off a one-shot backfill so the Map view is populated without a + # manual full re-scan. Imported lazily to avoid pulling Celery into + # the import graph for non-worker processes that don't need it. + if "sqlite" in settings.database_url and gps_columns_added: + try: + from app.tasks.scan import backfill_gps + backfill_gps.delay() + logger.info("Queued one-shot backfill_gps task after column add") + except Exception as e: + logger.warning(f"Could not queue backfill_gps task: {e}") + async def create_fts_table(): """Create Full-Text Search table for SQLite""" if "sqlite" in settings.database_url: diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index a678c39..5c8aaf4 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -1,7 +1,7 @@ """ Photo model definition """ -from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index +from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Index from sqlalchemy.sql import func from datetime import datetime import uuid @@ -49,6 +49,13 @@ class Photo(Base): # Metadata exif_json = Column(Text) # full EXIF/XMP blob as JSON + + # GPS coordinates extracted from EXIF, in signed decimal degrees + # (S latitude / W longitude are negative). Stored as first-class columns + # so the Map view and any future location filters can query/index them + # without parsing exif_json on every request. + latitude = Column(Float) + longitude = Column(Float) # User-editable fields user_title = Column(String) @@ -91,4 +98,5 @@ class Photo(Base): Index('ix_photos_color_label', 'color_label'), Index('ix_photos_media_type', 'media_type'), Index('ix_photos_processing_status', 'processing_status'), + Index('ix_photos_lat_lon', 'latitude', 'longitude'), ) \ No newline at end of file diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 4c1af2d..6b98aa8 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -62,6 +62,14 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): ) ).scalar() or 0 + with_gps_count = ( + await db.execute( + select(func.count(Photo.id)).where( + not_discarded, Photo.latitude.is_not(None) + ) + ) + ).scalar() or 0 + duplicates_count = ( await db.execute( select(func.count(Photo.id)).where( @@ -96,6 +104,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): "all_photos": all_photos_count, "rated": rated_count, "colored": colored_count, + "with_gps": with_gps_count, "duplicates": duplicates_count, "discarded": discarded_count, "total_photos": photo_count, @@ -108,11 +117,23 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): async def trigger_scan(): """Trigger full library re-scan""" from app.tasks.scan import scan_all_source_roots - + scan_all_source_roots.delay() - + return {"status": "success", "message": "Library scan started"} + +@router.post("/backfill-gps") +async def trigger_backfill_gps(): + """Re-run EXIF metadata extraction on every photo that's still missing + GPS coordinates. Useful after fixing the EXIF parser, or any time the + Map view looks emptier than expected. Returns immediately — work runs + on the Celery worker.""" + from app.tasks.scan import backfill_gps + + backfill_gps.delay() + return {"status": "success", "message": "GPS backfill queued"} + @router.get("/scan/status") async def get_scan_status(db: AsyncSession = Depends(get_db)): """Get current scan status""" diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 2ba964f..1528423 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -214,6 +214,36 @@ async def list_photos( "pages": (total + per_page - 1) // per_page if total else 0, } +@router.get("/map") +async def list_photos_with_gps(db: AsyncSession = Depends(get_db)): + """Lightweight listing of every non-discarded photo that has GPS + coordinates, used by the Map view. Intentionally returns a flat list + (no pagination) with only the fields the map renderer needs, so even + large libraries serialize to a few MB at most. Declared *before* + ``/{photo_id}`` so the literal path wins the FastAPI route match.""" + result = await db.execute( + select( + Photo.id, + Photo.latitude, + Photo.longitude, + Photo.taken_at, + ).where( + Photo.is_discarded.is_(False), + Photo.latitude.is_not(None), + Photo.longitude.is_not(None), + ) + ) + return [ + { + "id": row.id, + "latitude": row.latitude, + "longitude": row.longitude, + "taken_at": row.taken_at.isoformat() if row.taken_at else None, + } + for row in result.all() + ] + + @router.get("/{photo_id}") async def get_photo( photo_id: str, diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index 0af207e..685d6c2 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -36,6 +36,8 @@ class PhotoResponse(PhotoBase): processing_status: str = 'pending' processing_error: Optional[str] = None exif_json: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None is_duplicate: bool = False live_photo_video_id: Optional[str] = None # tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading diff --git a/backend/app/services/metadata.py b/backend/app/services/metadata.py index a2b000d..5106163 100644 --- a/backend/app/services/metadata.py +++ b/backend/app/services/metadata.py @@ -3,6 +3,7 @@ Metadata extraction service using ExifTool """ import json import logging +import re import asyncio from datetime import datetime from typing import Dict, Optional @@ -40,44 +41,132 @@ def parse_exif_datetime(date_str: str) -> Optional[datetime]: return None +_DMS_RE = re.compile( + r"""\s* + (?P-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s* + (?:(?P\d+(?:\.\d+)?)\s*[\'’m]?\s*)? + (?:(?P\d+(?:\.\d+)?)\s*[\"”s]?\s*)? + (?P[NSEW])?\s*$""", + re.IGNORECASE | re.VERBOSE, +) + + +def _parse_coord(value, ref: str | None) -> float | None: + """Coerce a single GPS coordinate from any form ExifTool may emit. + + ExifTool's ``-j`` JSON output applies print conversion by default, so + coordinates can come back as: + + * a number (``48.1278``) — happens for some sources / when ``-n`` is set + * a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude`` + * a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude`` + + The optional ``ref`` argument lets the caller pass an explicit + ``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the + string itself doesn't carry one. Returns signed decimal degrees, or + ``None`` if the value is unparseable. + """ + if value is None: + return None + # Numeric path — already decimal degrees, possibly already signed. + if isinstance(value, (int, float)): + out = float(value) + else: + m = _DMS_RE.match(str(value)) + if not m: + return None + deg = float(m.group('deg')) + minutes = float(m.group('min') or 0) + seconds = float(m.group('sec') or 0) + out = abs(deg) + minutes / 60.0 + seconds / 3600.0 + if deg < 0: + out = -out + embedded_ref = m.group('ref') + if embedded_ref: + ref = embedded_ref + if ref: + r = ref[0].upper() + if r in ('S', 'W'): + out = -abs(out) + elif r in ('N', 'E'): + out = abs(out) + return out + + +def extract_gps(exif_data: Dict) -> tuple: + """Return (lat, lon) in signed decimal degrees, or (None, None). + + With ``exiftool -G -j`` GPS values are keyed under their group. + ``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the + hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare + ``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate + ``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign. + + Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` / + ``GPSLongitude`` (which never exist in ``-G`` output) AND assumed + they were already floats — so it silently dropped every photo's GPS. + """ + lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None) + lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None) + if lat is None or lon is None: + lat = _parse_coord( + exif_data.get('EXIF:GPSLatitude'), + exif_data.get('EXIF:GPSLatitudeRef'), + ) + lon = _parse_coord( + exif_data.get('EXIF:GPSLongitude'), + exif_data.get('EXIF:GPSLongitudeRef'), + ) + if lat is None or lon is None: + return None, None + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + return None, None + # Some cameras emit (0, 0) when they have no GPS lock — treat as missing + if lat == 0 and lon == 0: + return None, None + return lat, lon + + def extract_key_metadata(exif_data: Dict) -> Dict: """Extract key metadata fields for FTS indexing""" key_fields = [] - + # Camera information - if 'Make' in exif_data: - key_fields.append(exif_data['Make']) - if 'Model' in exif_data: - key_fields.append(exif_data['Model']) - if 'LensModel' in exif_data: - key_fields.append(exif_data['LensModel']) - + if 'EXIF:Make' in exif_data: + key_fields.append(exif_data['EXIF:Make']) + if 'EXIF:Model' in exif_data: + key_fields.append(exif_data['EXIF:Model']) + if 'EXIF:LensModel' in exif_data: + key_fields.append(exif_data['EXIF:LensModel']) + # Location information - if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data: - key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}") - + lat, lon = extract_gps(exif_data) + if lat is not None and lon is not None: + key_fields.append(f"GPS: {lat}, {lon}") + # IPTC/XMP keywords - if 'Keywords' in exif_data: - if isinstance(exif_data['Keywords'], list): - key_fields.extend(exif_data['Keywords']) + keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject') + if keywords: + if isinstance(keywords, list): + key_fields.extend(keywords) else: - key_fields.append(exif_data['Keywords']) - + key_fields.append(keywords) + # Copyright and creator - if 'Copyright' in exif_data: - key_fields.append(exif_data['Copyright']) - if 'Creator' in exif_data: - key_fields.append(exif_data['Creator']) - if 'Artist' in exif_data: - key_fields.append(exif_data['Artist']) - + if 'EXIF:Copyright' in exif_data: + key_fields.append(exif_data['EXIF:Copyright']) + if 'XMP:Creator' in exif_data: + key_fields.append(exif_data['XMP:Creator']) + if 'EXIF:Artist' in exif_data: + key_fields.append(exif_data['EXIF:Artist']) + return { - 'exif_text': ' '.join(key_fields), - 'camera_make': exif_data.get('Make'), - 'camera_model': exif_data.get('Model'), - 'lens_model': exif_data.get('LensModel'), - 'gps_latitude': exif_data.get('GPSLatitude'), - 'gps_longitude': exif_data.get('GPSLongitude'), + 'exif_text': ' '.join(str(f) for f in key_fields), + 'camera_make': exif_data.get('EXIF:Make'), + 'camera_model': exif_data.get('EXIF:Model'), + 'lens_model': exif_data.get('EXIF:LensModel'), + 'gps_latitude': lat, + 'gps_longitude': lon, } @shared_task(name='extract_metadata') @@ -155,7 +244,13 @@ async def _extract_metadata_async(photo_id: str): photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth') if not photo.height: photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight') - + + # Extract GPS coordinates into first-class columns so the + # Map view can query them without parsing exif_json. + lat, lon = extract_gps(exif_data) + photo.latitude = lat + photo.longitude = lon + # Extract and store key metadata for search key_metadata = extract_key_metadata(exif_data) diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 507d4eb..59e82ed 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -456,16 +456,44 @@ def watch_folders(): async def handle_file_deletion(filepath: str): """Handle deletion of a file from the filesystem""" from sqlalchemy import select - + async with AsyncSessionLocal() as session: result = await session.execute( select(Photo).where(Photo.filepath == filepath) ) photo = result.scalar_one_or_none() - + if photo: # Mark as missing or delete from database photo.is_discarded = True photo.discarded_at = datetime.utcnow() await session.commit() - logger.info(f"Marked photo as discarded: {filepath}") \ No newline at end of file + logger.info(f"Marked photo as discarded: {filepath}") + + +@shared_task(name='backfill_gps') +def backfill_gps(): + """Re-run metadata extraction on every non-discarded photo that is + missing latitude/longitude. Used both as a one-shot kick-off after the + GPS columns are added on an existing install (see app/database.py) and + as a manual trigger from POST /api/v1/library/backfill-gps. Each + extract_metadata call is itself a Celery task, so this just enqueues — + it does not block on extraction completing.""" + return asyncio.run(_backfill_gps_async()) + + +async def _backfill_gps_async(): + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Photo.id).where( + Photo.latitude.is_(None), + Photo.is_discarded.is_(False), + ) + ) + photo_ids = [row[0] for row in result.all()] + + for pid in photo_ids: + extract_metadata.delay(pid) + + logger.info(f"backfill_gps: queued extract_metadata for {len(photo_ids)} photos") + return {'queued': len(photo_ids)} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 235dccd..aea0f16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "react-hotkeys-hook": "^4.4.3", "react-intersection-observer": "^9.5.3", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "tailwind-merge": "^2.2.0", "zustand": "^4.4.7" }, @@ -4572,6 +4573,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5249,6 +5259,21 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz", + "integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==", + "license": "SEE LICENSE IN ", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-leaflet": "^4.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e73e7f..00a0795 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "react-hotkeys-hook": "^4.4.3", "react-intersection-observer": "^9.5.3", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "tailwind-merge": "^2.2.0", "zustand": "^4.4.7" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f522f99..e70c0f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Timeline } from './components/timeline/Timeline' import { DuplicatesView } from './components/duplicates/DuplicatesView' +import { MapView } from './components/map/MapView' import { LeftSidebar } from './components/layout/LeftSidebar' import { RightSidebar } from './components/layout/RightSidebar' import { TopBar } from './components/layout/TopBar' @@ -73,11 +74,17 @@ function App() {
- {/* The Duplicates section gets its own grouped grid view — - * the regular timeline can't represent groups, and a flat - * filtered list of "is_duplicate=true" photos was the old - * half-broken UX. */} - {currentSection === 'duplicates' ? : } + {/* Section-level routing. The Map view replaces the timeline + * with a Leaflet map of GPS-tagged photos; Duplicates gets its + * own grouped grid; everything else falls through to the + * filter-driven Timeline. */} + {currentSection === 'map' ? ( + + ) : currentSection === 'duplicates' ? ( + + ) : ( + + )}
{/* Floating keyboard hints — bottom-center of the main column, * glassy. Mounted here so it's centered against the timeline, diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index bf36170..16d2609 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -11,6 +11,7 @@ import { Copy, Tag as TagIcon, Palette, + MapPin, Layers2, MoreHorizontal, Pencil, @@ -248,6 +249,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { case 'colors': navigateToSection('colors', { groupBy: 'color' }) break + case 'map': + navigateToSection('map', {}) + break default: if (id.startsWith('folder-')) { const folderId = id.slice('folder-'.length) @@ -349,6 +353,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, + { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, { id: 'duplicates', label: 'Duplicates', icon: , count: stats?.duplicates ?? 0 }, { id: 'discarded', label: 'Discarded', icon: , count: stats?.discarded ?? 0 }, ], diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/components/map/MapView.tsx new file mode 100644 index 0000000..bbec2dd --- /dev/null +++ b/frontend/src/components/map/MapView.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { MapContainer, TileLayer, Marker, useMap } from 'react-leaflet' +// react-leaflet-cluster has no own .d.ts that survives strict mode in +// every project, so we let TS infer from its runtime export. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore — package ships JS with no bundled types +import MarkerClusterGroup from 'react-leaflet-cluster' +import L from 'leaflet' +import { photos as photosApi } from '../../services/api' +import { usePhotoStore } from '../../store/photoStore' +import { MapPin } from 'lucide-react' + +interface MapPoint { + id: string + latitude: number + longitude: number + taken_at: string | null +} + +/** Build the divIcon used for each photo marker. The HTML is a tiny + * square thumbnail with a white border + drop shadow so it reads on + * any tile background. Memoised per-photo so we don't re-create the + * L.DivIcon on every re-render. */ +function buildPhotoIcon(photoId: string): L.DivIcon { + const url = photosApi.getThumbnailUrl(photoId, 'small') + return L.divIcon({ + className: 'photo-map-marker', + html: `
`, + iconSize: [56, 56], + iconAnchor: [28, 28], + popupAnchor: [0, -28], + }) +} + +/** Pans/zooms the map to fit the supplied points the first time they + * arrive. Subsequent loads (e.g. cache refresh) leave the user's pan + * alone — they're probably mid-investigation. */ +function FitBoundsOnce({ points }: { points: MapPoint[] }) { + const map = useMap() + const fittedRef = useRef(false) + useEffect(() => { + if (fittedRef.current || points.length === 0) return + const bounds = L.latLngBounds(points.map((p) => [p.latitude, p.longitude])) + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }) + fittedRef.current = true + }, [points, map]) + return null +} + +export function MapView() { + const { data: points = [], isLoading, error } = useQuery({ + queryKey: ['photos', 'map'], + queryFn: () => photosApi.mapPoints(), + staleTime: 60 * 1000, + }) + + // Marker click hands off to the same PreviewView the timeline uses, so + // the user gets the full preview UI (large image, info panel with + // location, filmstrip nav between map photos) instead of a bespoke + // map-only lightbox. We pass the map's own point order as the visible + // sequence so left/right arrows step through neighboring markers. + const openPreview = usePhotoStore((s) => s.openPreview) + + // Stable per-marker icons. Re-created only when the set of point ids + // changes — the underlying L.DivIcon objects are pure HTML so reusing + // them is safe across re-renders. + const iconsById = useMemo(() => { + const map = new Map() + for (const p of points) map.set(p.id, buildPhotoIcon(p.id)) + return map + }, [points]) + + const visibleSequence = useMemo(() => points.map((p) => p.id), [points]) + + if (isLoading) { + return ( +
+ Loading map… +
+ ) + } + + if (error) { + return ( +
+ Failed to load map photos +
+ ) + } + + return ( +
+ {points.length === 0 ? ( +
+
+ +
No photos with GPS data yet.
+
+ Re-run metadata extraction from Settings → Backfill GPS to + populate coordinates from existing photos. +
+
+
+ ) : null} + + + + + + {points.map((p) => { + const icon = iconsById.get(p.id) + if (!icon) return null + return ( + openPreview(p.id, visibleSequence)} + /> + ) + })} + + + + +
+ ) +} + +function PhotoMarker({ + point, + icon, + onClick, +}: { + point: MapPoint + icon: L.DivIcon + onClick: () => void +}) { + return ( + + ) +} diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index 86c0347..10ab453 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useHotkeys } from 'react-hotkeys-hook' import { X, Info } from 'lucide-react' import { usePhotoStore } from '../../store/photoStore' import { usePhotosQuery } from '../../hooks/usePhotosQuery' +import { photos as photosApi } from '../../services/api' import type { Photo } from '../../types/photo' import { PreviewImage } from './PreviewImage' import { PreviewFilmstrip } from './PreviewFilmstrip' @@ -43,7 +45,24 @@ export function PreviewView() { ? photos.findIndex((p) => p.id === activePhotoId) : 0 const safeIndex = currentIndex < 0 ? 0 : currentIndex - const currentPhoto: Photo | undefined = photos[safeIndex] + + // Fallback fetch: when the preview is opened for a photo that isn't in + // the timeline query result (e.g. clicked from the Map view, where the + // active section's filter excludes it), look it up by id directly. + // PhotoInfoPanel runs the same query under the same key, so they share + // one cache entry — no extra request. + const photoInListById = activePhotoId + ? photos.find((p) => p.id === activePhotoId) + : undefined + const { data: standalonePhoto } = useQuery({ + queryKey: ['photo', activePhotoId], + queryFn: () => photosApi.get(activePhotoId as string), + enabled: !!activePhotoId && !photoInListById, + staleTime: 60_000, + }) + + const currentPhoto: Photo | undefined = + photoInListById ?? photos[safeIndex] ?? standalonePhoto // Keep the latest photos array + active id in a ref so the keyboard // handlers ALWAYS read the freshest state. Without this, react-hotkeys- diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index 3f1c51e..d1e4119 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -49,9 +49,20 @@ interface PhotoDetails { user_notes: string | null color_label: string | null exif_json: string | null + latitude?: number | null + longitude?: number | null tags?: PhotoTagSummary[] } +/** Format a signed decimal degree value with the hemisphere letter, e.g. + * ``48.12777° N``. Keeps the panel readable without dragging in a heavy + * formatting lib. */ +function formatLatLon(value: number, axis: 'lat' | 'lon'): string { + const abs = Math.abs(value).toFixed(5) + const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W') + return `${abs}° ${ref}` +} + interface ExifData { Make?: string Model?: string @@ -547,13 +558,20 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro expanded={expandedSections.has('location')} onToggle={() => toggleSection('location')} > - {exif.GPSLatitude && exif.GPSLongitude ? ( - + ) : (
No GPS data
)} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 052f2f4..91b527f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import App from './App' import './index.css' +// Leaflet ships its base styles separately; without these the map tiles +// render at 0×0 and marker icons go missing. Imported once globally so +// any lazy MapView render picks them up. +import 'leaflet/dist/leaflet.css' const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 10e0cc0..c8e4a26 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -97,6 +97,20 @@ export const photos = { return response.data }, + /** Lightweight list of every photo with GPS coordinates, used by the + * Map view. Returns one tiny object per photo (id, lat, lon, taken_at) + * rather than the full photo payload — keeps responses small even on + * big libraries. */ + mapPoints: async () => { + const response = await api.get('/photos/map') + return response.data as Array<{ + id: string + latitude: number + longitude: number + taken_at: string | null + }> + }, + update: async (photoId: string, data: { filename?: string rating?: number @@ -410,6 +424,7 @@ export interface LibraryStats { all_photos: number rated: number colored: number + with_gps: number duplicates: number discarded: number total_photos: number diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index d258f63..bda58de 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -22,5 +22,16 @@ export interface Photo { thumb_small?: string thumb_medium?: string thumb_large?: string + latitude?: number | null + longitude?: number | null tags?: PhotoTagSummary[] } + +/** Minimal payload returned by GET /api/v1/photos/map — only what the + * Map view needs to render and open a marker's preview. */ +export interface PhotoMapPoint { + id: string + latitude: number + longitude: number + taken_at: string | null +}