feat(journey): geotagged photos on the journey's own map, both renderers (#2453)

This commit is contained in:
Maurice
2026-10-01 13:59:10 +02:00
parent f4d81ea42a
commit f875c85474
10 changed files with 191 additions and 53 deletions
+3 -46
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -22,44 +23,6 @@ export interface MapMarkerItem {
photoUrls: string[]
}
/**
* Grid clustering in screen space.
*
* The Journey maps have never had clustering, and the library the planner uses
* hangs off react-leaflet while this map drives Leaflet directly. Bucketing by
* rounded pixel position is a few lines, is deterministic, and is enough for the
* job: photos of one place collapse into one thumbnail with a count, and pulling
* the map apart separates them again.
*/
const PHOTO_CLUSTER_PX = 64
function clusterPhotos(
map: L.Map,
photos: MapPhoto[],
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = map.latLngToContainerPoint([photo.lat, photo.lng])
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
}
function photoMarkerHtml(thumbUrl: string, count: number): string {
const badge = count > 1
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>`
: ''
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}`
}
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
@@ -67,13 +30,7 @@ export interface JourneyMapHandle {
invalidateSize: () => void
}
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
lat: number
lng: number
thumbUrl: string
}
export type { MapPhoto } from './journeyPhotoLayer'
interface MapEntry {
id: string
@@ -490,7 +447,7 @@ function JourneyMap(
}
const group = L.layerGroup()
for (const cluster of clusterPhotos(map, photos)) {
for (const cluster of clusterPhotos(photos, (lat, lng) => map.latLngToContainerPoint([lat, lng]))) {
const marker = L.marker([cluster.lat, cluster.lng], {
icon: L.divIcon({
className: '',
@@ -6,6 +6,7 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer'
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -37,6 +38,9 @@ interface Props {
hideMarkerTooltip?: boolean
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
}
function JourneyMapAuto({ ref, ...props }: Props) {
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn>
@@ -32,6 +32,11 @@ interface BoundsStub {
const gl = vi.hoisted(() => {
const map = {
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn(() => true),
// Container pixels from lng/lat, ten per degree: enough to tell near from far.
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
remove: vi.fn(),
resize: vi.fn(),
flyTo: vi.fn(),
@@ -424,7 +429,7 @@ describe('JourneyMapGL', () => {
const firstInner = gl.markers[0].element.querySelector('.trek-journey-marker-inner') as HTMLElement
expect(firstInner.style.transform).toBe('scale(1)')
expect(gl.markers[0].element.style.zIndex).toBe('0')
expect(gl.markers[0].element.style.zIndex).toBe('1')
})
it('FE-COMP-JMAPGL-019: dark mode tags the popup class on creation', () => {
@@ -450,7 +455,7 @@ describe('JourneyMapGL', () => {
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
act(() => { ref.current!.highlightMarker('ghost') })
expect(gl.popups).toHaveLength(0)
expect(gl.markers[0].element.style.zIndex).toBe('')
expect(gl.markers[0].element.style.zIndex).toBe('1')
})
it('FE-COMP-JMAPGL-022: the popup stylesheet is injected exactly once', () => {
@@ -500,6 +505,24 @@ describe('JourneyMapGL', () => {
expect(gl.map.flyTo).not.toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-2453: geotagged photos become clustered thumbnails under the pins and open on a tap', () => {
withToken()
const onPhotoClick = vi.fn()
const photos = [
{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' },
{ id: 'b', lat: 45.1, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
{ id: 'c', lat: 48, lng: 11, thumbUrl: '/api/photos/3/thumbnail' },
]
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} onPhotoClick={onPhotoClick} />)
const thumbs = gl.markers.filter(m => m.element.innerHTML.includes('/api/photos/'))
// a and b fall in one 64px bucket at ten pixels a degree, c in its own.
expect(thumbs).toHaveLength(2)
expect(thumbs[0].element.innerHTML).toContain('>2<')
thumbs[0].element.click()
expect(onPhotoClick).toHaveBeenCalledWith(['a', 'b'])
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
})
it('FE-COMP-JMAPGL-026: focusMarker on an unknown id is a no-op', () => {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
+47 -2
View File
@@ -5,6 +5,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings,
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -47,6 +48,9 @@ interface Props {
* card stays the inert label it has always been.
*/
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
/** Photos placed by their own capture coordinates, clustered by proximity (#2453). */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number
glProvider?: GlMapProvider
/**
@@ -128,7 +132,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
@@ -158,6 +162,9 @@ function JourneyMapGL(
const popupRef = useRef<any | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
const darkRef = useRef(dark)
darkRef.current = dark
const mapLangRef = useRef(mapLang)
@@ -239,7 +246,7 @@ function JourneyMapGL(
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML
el.style.zIndex = highlighted ? '1000' : '0'
el.style.zIndex = highlighted ? '1000' : '1'
}, [])
const highlightMarker = useCallback((id: string | null) => {
@@ -390,6 +397,8 @@ function JourneyMapGL(
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
el.style.zIndex = '1'
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
@@ -427,6 +436,42 @@ function JourneyMapGL(
}
}, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Photo layer (#2453), the GL twin of the Leaflet one: its own effect so photos
// arriving do not rebuild the map, redrawn on every move because the clustering
// is done in screen space. Same dependencies as the build effect above, so it
// attaches to the map that effect just made.
useEffect(() => {
const map = mapRef.current
if (!map) return
const clear = () => {
photoMarkersRef.current.forEach(m => m.remove())
photoMarkersRef.current = []
}
const draw = () => {
clear()
if (!photos?.length) return
for (const cluster of clusterPhotos(photos, (lat, lng) => map.project([lng, lat]))) {
const el = document.createElement('div')
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onPhotoClickRef.current?.(cluster.members.map(m => m.id))
})
photoMarkersRef.current.push(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([cluster.lng, cluster.lat]).addTo(map))
}
}
if (map.loaded()) draw()
else map.once('load', draw)
map.on('moveend', draw)
return () => {
map.off('moveend', draw)
map.off('load', draw)
clear()
}
// gl is the injected engine and does not change for the life of a map.
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run.
useEffect(() => {
@@ -0,0 +1,19 @@
// FE-JOURNEY-PHOTOLAYER-001
import { describe, expect, it } from 'vitest'
import { clusterPhotos, photoMarkerHtml } from './journeyPhotoLayer'
describe('journey photo layer (#2453)', () => {
it('FE-JOURNEY-PHOTOLAYER-001: buckets by screen position, anchors on the first photo, badges a count', () => {
const photos = [
{ id: '1', lat: 1, lng: 1, thumbUrl: '/a.jpg' },
{ id: '2', lat: 1.2, lng: 1.2, thumbUrl: '/b.jpg' },
{ id: '3', lat: 50, lng: 50, thumbUrl: '/c.jpg' },
]
const clusters = clusterPhotos(photos, (lat, lng) => ({ x: lng * 10, y: lat * 10 }))
expect(clusters.map(c => c.members.map(m => m.id))).toEqual([['1', '2'], ['3']])
expect(clusters[0]).toMatchObject({ lat: 1, lng: 1 })
expect(photoMarkerHtml('/a b.jpg', 1)).toContain("url('/a%20b.jpg')")
expect(photoMarkerHtml('/a.jpg', 1)).not.toContain('</span>')
expect(photoMarkerHtml('/a.jpg', 3)).toContain('>3<')
})
})
@@ -0,0 +1,47 @@
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
lat: number
lng: number
thumbUrl: string
}
/**
* Grid clustering in screen space, shared by the Leaflet and the GL journey map.
*
* The Journey maps have never had clustering, and the library the planner uses
* hangs off react-leaflet while these maps drive their engines directly.
* Bucketing by rounded pixel position is a few lines, is deterministic, and is
* enough for the job: photos of one place collapse into one thumbnail with a
* count, and pulling the map apart separates them again. `project` is the
* engine's own lat/lng to container-pixel conversion.
*/
export const PHOTO_CLUSTER_PX = 64
export function clusterPhotos(
photos: MapPhoto[],
project: (lat: number, lng: number) => { x: number; y: number },
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = project(photo.lat, photo.lng)
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
}
export function photoMarkerHtml(thumbUrl: string, count: number): string {
const badge = count > 1
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>` // theme-lint-disable: map marker chrome, drawn over any basemap
: ''
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}` // theme-lint-disable: map marker chrome, drawn over any basemap
}
+3 -1
View File
@@ -57,7 +57,7 @@ function JourneyDetailPageDesktop() {
dawarichByDate, dawarichBusyId, acceptDawarich, dismissDawarich,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos,
@@ -668,6 +668,8 @@ function JourneyDetailPageDesktop() {
checkins={[]}
entries={sidebarMapItems as any}
tracks={tracks}
photos={mapPhotos}
onPhotoClick={openMapPhotos}
height={9999}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
@@ -336,6 +336,23 @@ describe('useJourneyDetail', () => {
expect(latest.sidebarMapItems[0].dayColor).toBe(latest.sidebarMapItems[1].dayColor);
});
it('FE-JRN-DETHOOK-2453: geotagged gallery photos become the map layer, and a tap opens them', async () => {
serveJourney(buildDetail({
gallery: [
{ id: 3, photo_id: 30, lat: 48.1, lng: 11.5, caption: 'Marienplatz' },
{ id: 4, photo_id: 40, lat: null, lng: null },
] as never,
}));
setup();
await waitFor(() => expect(latest.mapPhotos).toHaveLength(1));
expect(latest.mapPhotos[0]).toEqual({ id: '3', lat: 48.1, lng: 11.5, thumbUrl: '/api/photos/30/thumbnail' });
act(() => latest.openMapPhotos(['3']));
expect(latest.lightbox?.photos).toEqual([expect.objectContaining({ id: 3, src: '/api/photos/30/original', caption: 'Marienplatz' })]);
act(() => latest.setLightbox(null));
act(() => latest.openMapPhotos(['999']));
expect(latest.lightbox).toBeNull();
});
it('FE-JRN-DETHOOK-019: tripDates expands linked trips and skips half-dated ones', async () => {
serveJourney(buildDetail({
trips: [
@@ -11,7 +11,7 @@ import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionModel'
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
@@ -419,6 +419,25 @@ export function useJourneyDetail() {
})
}, [mapEntries, current?.entries])
/**
* The journey's geotagged photos as thumbnails on its map (#2453), the same
* layer the shared view has had since #1614. A tap opens the photos of that
* cluster in the lightbox.
*/
const mapPhotos = useMemo(() => (current?.gallery ?? [])
.filter(p => typeof p.lat === 'number' && typeof p.lng === 'number')
.map(p => ({ id: String(p.id), lat: p.lat as number, lng: p.lng as number, thumbUrl: photoUrl(p, 'thumbnail') })),
[current?.gallery])
const openMapPhotos = useCallback((ids: string[]) => {
const wanted = new Set(ids)
const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id)))
if (!photos.length) return
setLightbox({
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
index: 0,
})
}, [current?.gallery])
/**
* Where a journey should open: on today, when today is part of it.
*
@@ -544,6 +563,7 @@ export function useJourneyDetail() {
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
mapPhotos, openMapPhotos,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos: providerPhotos.addPickedPhotos,
+4
View File
@@ -113,6 +113,10 @@ export interface GalleryPhoto {
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
/** Where and when it was taken, from EXIF or the provider (#1614); null when unknown. */
taken_at?: string | null
lat?: number | null
lng?: number | null
}
export interface JourneyTrip {