From leaflet popup marker to photo gallery image and back
Alessandro T.
Posted on May 22, 2024
From marker map to photo gallery image and back
When I go hiking often I take photos of the landscape I cross. For this reason I try to prepare some gps tracks for my walks and excursions, so I started adding markers to show the position where I took the photos.
I display my photos using PhotoSwipe and I use leaflet to handle geographical maps. From a technical point of view I display on the map:
- geojson for gps tracks of hiking
- for every photo there is a marker with a popup containing a thumbnail and a link to the corresponding photo gallery image
Also for every photo gallery page I add a PhotoSwipe gallery containing some custom ui html elements: an svg icon referring the photo marker within the map and a custom onClick()
function.
These components can work together thanks to a global state handled by pinia
. First I defined the pinia store:
// .vitepress/store/stores.ts
import { defineStore } from "pinia";
const mapStore = defineStore('map-store', {
state: () => {
return {
closePopup: Boolean,
markers: [],
selectedPopupCoordinate: Array,
selectedPopupId: Number,
selectedPopupIdWithCoordinates: Number,
selectedImageIndex: Number,
}
}
})
export { mapStore }
I use two Vue components: GalleryComponent.vue
and MapComponent.vue
.
How to open the correct photo within the photo gallery clicking on a marker popup
Every popup marker contains a link that, on click, patch the pinia
state with the selected photo ID (and the corresponding marker ID). The tricky part was connect this function to the a
HTML popup element, so I created a wrapper function getPopup()
that before creates the HTML elements with the correct onClick()
function I already talked about (lines 17-23):
// MapComponent.vue
// define the pinia state store
if (inBrowser && localStore == null) {
localStore = photoStore();
}
/* ... */
function getPopup(id, titleContent, urlthumb): HTMLDivElement {
// manually build html elements to set global pinia state from here
const title: HTMLSpanElement = document.createElement("span")
title.innerHTML = `${titleContent}`
const a: HTMLAnchorElement = document.createElement("a");
a.id = `popup-a-${id}`
// this action opens the selected photo within the photo gallery and close this marker popup
a.onclick = function eventClick(event) {
event.preventDefault()
localStore.$patch({
selectedImageIndex: id,
closePopup: true
})
}
a.appendChild(title)
const div: HTMLDivElement = document.createElement("div");
div.appendChild(a)
return div
}
Of course the Vue component should contains also a way the read the updated store and here I use the selectedImageIndex
variable to open the selected image within the photo gallery:
// GalleryComponent.vue
let localMapStore;
if (inBrowser && localMapStore == null) {
localMapStore = mapStore();
localMapStore.$subscribe((mutation, state) => {
const {payload} = mutation;
const {selectedImageIndex} = payload;
// open the selected photo within the photo gallery
if (selectedImageIndex != undefined) {
handleGalleryOpen(selectedImageIndex)
}
})
}
/* ... */
const handleGalleryOpen = (index) => {
// from https://github.com/hzpeng57/vue-preview-imgs/blob/master/packages/example/src/App.vue
lightbox.loadAndOpen(parseInt(index, 10));
};
Going backwards: from a photo within the photo gallery to the corresponding popup marker
When a user clicks the link inside the popup marker to open the corresponding photo the sequence of actions is simple: first clickingthe popup link patches the pinia
state and then the store instance uses the method .$subscribe({...})
to open the selected photo in the photo gallery. Easy.
The reverse process, however, is more complicated: the leaflet
map can't show a marker if before it hasn't set the map view using the correct marker coordinates, but the photo gallery knows only about the current image index itself (lines 24-29):
onMounted(() => {
const galleryDiv: HTMLElement | null = document.getElementById(`gallery-photo-${props.galleryID}`)
const galleryChildren: HTMLCollection | undefined = galleryDiv?.children
const dataSource = {
gallery: galleryDiv,
items: galleryChildren
}
const options = {
gallery: `#gallery-photo-${props.galleryID}`,
children: 'a',
pswpModule: () => import('photoswipe'),
dataSource: dataSource // fix missing gallery on first load with custom lightbox.loadAndOpen() action
}
if (lightbox != new PhotoSwipeLightbox({})) {
lightbox = new PhotoSwipeLightbox(options);
lightbox.on('uiRegister', function () {
lightbox.pswp.ui.registerElement({
name: 'location-button',
order: 8,
isButton: true,
tagName: 'a',
html: '<svg code ... />',
// onClick function for the custom position button within GalleryComponent.vue, onMount() hook
onClick: function (event, el, pswp) {
localMapStore.$patch({
selectedPopupIdFromGallery: parseInt(pswp.currSlide.index, 10)
})
pswp.close()
}
});
});
lightbox.init();
}
})
Note on line 12: that's a workaround needed to avoid the gallery has missing content on the first load when using a custom lightbox.loadAndOpen()
method.
Because of the missing marker coordinates during the former patch store action I added an intermediate step where I filter the markers I already put within the store to extract the selected marker coordinates:
// GalleryComponent.vue
import { LatLngTuple } from "leaflet";
// ...
let localMapStore;
if (inBrowser && localMapStore == null) {
localMapStore = mapStore();
localMapStore.$subscribe((mutation, state) => {
const {payload} = mutation;
const {selectedPopupIdFromGallery} = payload;
// filter the markers to select the marker coordinates used to set the marker map view
let {markers} = state;
let selectedMarkers: [] = markers[props.galleryID]
if (selectedPopupIdFromGallery != undefined && selectedMarkers != undefined) {
// m.id: number type!
let filteredMarker = selectedMarkers.find(m => m.id == selectedPopupIdFromGallery)
const coordinate: LatLngTuple = filteredMarker.coordinate
localMapStore.$patch({
selectedPopupCoordinate: coordinate,
selectedPopupIdWithCoordinates: filteredMarker.id
})
}
})
}
Note that in this case the objects within the markers
array contain at least id
(a number variable) and coordinate
(LatLngTuple type from leaflet
).
Next phase should use the store payload content (selectedPopupIdWithCoordinates
and selectedPopupCoordinate
) to set the current map view where the selected marker is and then to open his popup:
// MapComponent.vue
const zoomValue = 18
const popupContent = getPopup(/* ... */)
let popup = L.popup(m.coordinate).setContent(popupContent)
const marker = L.marker(coordinate, {/* ... */}).bindPopup(popup);
// here add the current marker to the marker cluster instance...
localMapStore.$subscribe((mutation, state) => {
const {payload} = mutation;
const {closePopup, selectedPopupIdWithCoordinates, selectedPopupCoordinate} = payload;
if (selectedPopupIdWithCoordinates == m.idx && selectedPopupCoordinate) {
// m.id: number type!
map.setView(selectedPopupCoordinate, zoomValue)
marker.openPopup()
}
if (closePopup) {
marker.closePopup()
}
})
// ...
And... that's the way you do it!
Posted on May 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.