Skip to content

From marker map to photo gallery image and back

When I go hiking often I take photo of landscape I cross. For this reason I try to prepare some gps track for my walks and excursions, so I started adding markers to show the position where I took the photos.

I display my photos on page using PhotoSwipe and I use leaflet to handle geographical maps. From technical point of view I display on 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:

ts
// .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.

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):

ts
// 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 vuejs 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:

ts
// 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));
};

When a user clicks on the link within the popup marker to open the corresponding photo the sequence of actions is simple: first clicking on the popup link patch the pinia state and then the store instance con il metodo .$subscribe({...}) opens the selected photo within 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):

ts
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:

ts
// 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:

ts
// 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!

Like my website? Pay me a coffee
References are available upon request. I hereby authorize the use of my personal data in compliance with the Italian D. Lgs. 196/2003, art. 13 for the purpose of making me job offers.