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:
2026-04-09 23:44:29 +02:00
parent 9c9f5bd899
commit 7cf546af7a
17 changed files with 529 additions and 46 deletions

View 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='&copy; <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 }}
/>
)
}