From leaflet popup marker to photo gallery image and back

trincadev

Alessandro T.

Posted on May 22, 2024

From leaflet popup marker to photo gallery image and back

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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));
};
Enter fullscreen mode Exit fullscreen mode

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();
  }
})
Enter fullscreen mode Exit fullscreen mode

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
      })
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  })
  // ...
Enter fullscreen mode Exit fullscreen mode

And... that's the way you do it!

💖 💪 🙅 🚩
trincadev
Alessandro T.

Posted on May 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related