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

@@ -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>
)}