feat: map view with GPS extraction fix
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) <noreply@anthropic.com>
This commit is contained in:
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@@ -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 <LICENSE>",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
<FilterBar />
|
||||
<DiscardActionBar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 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' ? <DuplicatesView /> : <Timeline />}
|
||||
{/* 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' ? (
|
||||
<MapView />
|
||||
) : currentSection === 'duplicates' ? (
|
||||
<DuplicatesView />
|
||||
) : (
|
||||
<Timeline />
|
||||
)}
|
||||
</div>
|
||||
{/* Floating keyboard hints — bottom-center of the main column,
|
||||
* glassy. Mounted here so it's centered against the timeline,
|
||||
|
||||
@@ -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: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
|
||||
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
||||
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
||||
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||
],
|
||||
|
||||
169
frontend/src/components/map/MapView.tsx
Normal file
169
frontend/src/components/map/MapView.tsx
Normal file
@@ -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: `<div class="pmm-frame"><img src="${url}" loading="lazy" alt="" /></div>`,
|
||||
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<string, L.DivIcon>()
|
||||
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 (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading map…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-destructive">
|
||||
Failed to load map photos
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{points.length === 0 ? (
|
||||
<div className="absolute inset-0 z-[400] flex items-center justify-center bg-background/80 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-center text-sm text-muted-foreground">
|
||||
<MapPin className="h-6 w-6" />
|
||||
<div>No photos with GPS data yet.</div>
|
||||
<div className="text-xs">
|
||||
Re-run metadata extraction from Settings → Backfill GPS to
|
||||
populate coordinates from existing photos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
minZoom={2}
|
||||
worldCopyJump
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitBoundsOnce points={points} />
|
||||
<MarkerClusterGroup chunkedLoading maxClusterRadius={50}>
|
||||
{points.map((p) => {
|
||||
const icon = iconsById.get(p.id)
|
||||
if (!icon) return null
|
||||
return (
|
||||
<PhotoMarker
|
||||
key={p.id}
|
||||
point={p}
|
||||
icon={icon}
|
||||
onClick={() => openPreview(p.id, visibleSequence)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
<style>{`
|
||||
.photo-map-marker { background: transparent; border: none; }
|
||||
.photo-map-marker .pmm-frame {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
background: #1f2937;
|
||||
}
|
||||
.photo-map-marker .pmm-frame img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoMarker({
|
||||
point,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
point: MapPoint
|
||||
icon: L.DivIcon
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Marker
|
||||
position={[point.latitude, point.longitude]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: onClick }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<Photo>({
|
||||
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-
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{photo.latitude != null && photo.longitude != null ? (
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 text-xs hover:underline"
|
||||
title="Open in OpenStreetMap"
|
||||
>
|
||||
<MapPin className="h-3 w-3 text-text-muted" />
|
||||
<span className="font-mono text-text">
|
||||
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||||
{formatLatLon(photo.latitude, 'lat')},{' '}
|
||||
{formatLatLon(photo.longitude, 'lon')}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-xs text-text-muted">No GPS data</div>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user