From leaflet popup marker to photo gallery image and back
Tags: javascript, vue, typescript, maps
Published: 2024-05-22 18:31:24

## 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](https://photoswipe.com/) and I use [leaflet](https://leafletjs.com/) 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](https://photoswipe.com/adding-ui-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`.

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

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

```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));
};
```

## 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.

<iframe width="560" height="315" src="https://www.youtube.com/embed/B5oO0PT_-Ao?si=B62c9c5Efi8idMRz&amp;start=50" title="Going Backwards" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

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_!

{% embed https://www.youtube.com/watch?v=wTP2RUD_cL0?start=65 %}