diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 4b74d93c8..57f50dae5 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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() - 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 - ? `${count}` - : '' - return `
${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: '', diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 24d0a486f..5880557e2 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -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) { diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index c9b99ee58..7fd69b831 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -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 @@ -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() 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() + 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() diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index c8bfd46bf..b911c1523 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -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(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(() => { diff --git a/client/src/components/Journey/journeyPhotoLayer.test.ts b/client/src/components/Journey/journeyPhotoLayer.test.ts new file mode 100644 index 000000000..1980cacf9 --- /dev/null +++ b/client/src/components/Journey/journeyPhotoLayer.test.ts @@ -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('') + expect(photoMarkerHtml('/a.jpg', 3)).toContain('>3<') + }) +}) diff --git a/client/src/components/Journey/journeyPhotoLayer.ts b/client/src/components/Journey/journeyPhotoLayer.ts new file mode 100644 index 000000000..f478b65e4 --- /dev/null +++ b/client/src/components/Journey/journeyPhotoLayer.ts @@ -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() + 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 + ? `${count}` // theme-lint-disable: map marker chrome, drawn over any basemap + : '' + return `
${badge}` // theme-lint-disable: map marker chrome, drawn over any basemap +} diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 70bbd7a20..fc2b272b1 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -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} diff --git a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx index 826dfb697..28c1e9a79 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx +++ b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx @@ -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: [ diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 783686563..cf0c14791 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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, diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 838be8fbb..c3427c30e 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -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 {