Da un marker leaflet alla foto in una galleria fotografica e ritorno
Tags: javascript, vue, typescript, maps
Published: 2024-05-22 18:44:08

## Da un marker leaflet alla foto in una galleria fotografica e ritorno

Quando vado a fare escursioni spesso scatto foto dei dintorni. Per questo motivo cerco di registrare il percorso delle mie passeggiate insieme ai segnalibri con la posizione delle foto.

Al momento uso [Leaflet](https://leafletjs.com/) per gestire le mappe geografiche e [PhotoSwipe](https://photoswipe.com/) per le gallerie fotografiche. Dal punto di vista tecnico visualizzo sulla mappa:

- i geojson contenenti le tracce gps delle escursioni
- per ogni foto i segnalibri con le coordinate delle foto a cui è connesso un popup contenente una thumbnail della foto ed un collegamento all'immagine nella photogallery corrispondente

Inoltre in questo caso personalizzo la galleria `photoswipe` aggiungendo alcuni [elementi html custom dell'interfaccia grafica](https://photoswipe.com/adding-ui-elements/) (un'icona SVG che rimanda al segnalibro della foto all'interno della mappa con una funzione `onClick()` custom).

Questi componenti possono lavorare insieme grazie ad uno stato globale gestito da `pinia`. Per prima cosa ho creato lo store contenente lo stato globale:

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

Definiamo inoltre due componenti vue: `GalleryComponent.vue` e `MapComponent.vue`.

##  1. Come aprire la foto selezionata all'interno della galleria fotografica facendo clic sul popup del marker leaflet

Ogni popup leaflet contiene un collegamento che, al clic, aggiorna lo store usando l'ID della foto selezionata (ovvero l'ID del marker corrispondente). La parte difficile è stata connettere la funzione che aggiorna lo store al HTMLAnchorElement `a` contenuto nel popup. Per superare l'ostacolo ho creato una funzione wrapper `getPopup()` che prima crea gli elementi HTML necessari, fra cui anche l'elemento HTML `a` con il metodo custom `onClick()` di cui sopra (righe 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
}
```

Ovviamente il componente Vue dovrebbe contenere anche un modo per leggere lo store aggiornato da cui estrarre la variabile `selectedImageIndex` con cui aprire poi l'immagine nella galleria fotografica:

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

## 2. Andando indietro: da una foto all'interno della galleria fotografica al corrispondente indicatore popup

Quando un utente fa clic sul collegamento all'interno del popup per aprire la foto corrispondente, la sequenza di azioni è semplice: prima facendo clic sul collegamento nel popup si aggiorna lo store `pinia`. In seguito l'istanza dello store con il metodo `.$subscribe({...})` apre la foto selezionata all'interno della galleria fotografica. Facile.

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

Il processo inverso, però, è più complicato: la mappa `leaflet` non può aprire il popup senza aver preventivamente impostato la view corrispondente alle coordinate del marker selezionato. La galleria fotografica però ha soltanto il dato relativo al ID dell'immagine stessa attualmente aperta (righe 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();
  }
})
```

Notare la riga 12: si tratta di un workaround necessario per evitare che il contenuto della galleria sia mancante al primo caricamento custom (in questo caso usando `lightbox.loadAndOpen()`) della galleria.

A causa delle coordinate mancanti del marker nella fase precedente ho aggiunto un passaggio intermedio si filtrano i marker estratti dallo store per ricavare le coordinate del marker selezionato:

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

Si noti che in questo caso gli oggetti dentro all'array `markers` conterranno almeno `id` (tipo numero) e `coordinate` (`LatLngTuple` dalla libreria `leaflet`).
La fase successiva usa il contenuto del payload dello store (`selectedPopupIdWithCoordinates` e `selectedPopupCoordinate` in particolare) per impostare la visualizzazione corrente della mappa in cui si trova il segnalibro selezionato ed aprirne quindi finalmente il 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()
    }
  })
  // ...
```

E... _that's the way you do it_!

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