Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customizing Icon image? #9

Closed
webrgp opened this issue Aug 1, 2023 · 4 comments
Closed

Customizing Icon image? #9

webrgp opened this issue Aug 1, 2023 · 4 comments
Labels
Feature Request New feature or request

Comments

@webrgp
Copy link

webrgp commented Aug 1, 2023

Can you provide an example of customizing an marker icon image using your plugin? Thanks!

@cedrvanh
Copy link

cedrvanh commented Aug 10, 2023

@webrgp Have you found a way to do it? I'm trying to figure out the same thing right now

@webrgp
Copy link
Author

webrgp commented Aug 10, 2023

Yes @cedrvanh, but it took a lot of looking at both mapbox.js and dynamicmap.js. Here is the gist of it:

In the template that will render the map, it's mostly how the docs describe. I've opted to feed the entries as an array so I could set the marker id, which happens to be the entry id.

{% set locations = craft.entries({
    section: 'mapLocations',
    with: [
      'locationCover',
      'programCategory',
      'programCategory.programIcon',
    ]
  })
  .collect()
  .map( l => {
    id: l.id,
    title: l.title,
    lat: l.address.lat,
    lng: l.address.lng,
    color: l.programCategory[0].programColor.color[0].color,
    iconUrl: l.programCategory[0].programIcon[0].url|default(null),
  })
  .toArray()
%}

{% set map = mapbox.map(locations, {
  id: 'impact-map',
  width: '100%',
  style: 'light-v11',
  mapOptions: {
    maxZoom: 11,
    minZoom: 6,
  },
  popupTemplate: '_partials/_mapPopup',
  popupOptions: {
    focusAfterOpen: false,
    closeButton: false,
    anchor: 'bottom',
    maxWidth: '320px',
    offset: [0, -24]
  }
}) %}

<div x-data="impactMap" class="flex flex-col gap-4">
  <div class="max-w-screen-narrow mx-auto w-full aspect-[4/6] md:aspect-[8/5]">
    {{ map.tag({ init: false }) }}
  </div>
</div>

I've implemented the javascript part using AlpineJS components:

import { AlpineComponent } from 'alpinejs'
import {
  FitBoundsOptions,
  LngLatBounds,
  LngLatLike,
  Map as MBMap,
  Marker,
  MarkerOptions,
  Popup
} from 'mapbox-gl'

// Shim the Mapbox Craft plugin JS Interfaces
interface IDADynamicMap {
  div: null | HTMLElement
  _map: MBMap
  _locationBounds: (locations: LngLatLike[]) => LngLatBounds
  _checkMapVisibility: () => void
  getBounds: () => LngLatBounds
  changeMarker: (markerId: string | number, options: MarkerOptions) => IDADynamicMap
  panToMarker: (locationId: string | number) => IDADynamicMap
  openPopup: (popupId: string | number) => IDADynamicMap
  closePopup: (popupId: string | number) => IDADynamicMap
  showMarker: (markerId: string | number) => IDADynamicMap
  hideMarker: (markerId: string | number) => IDADynamicMap
  getMarker: (markerId: string | number, assumeSuccess?: boolean) => Marker
  getPopup: (popupId: string | number, assumeSuccess?: boolean) => Popup
  fit: (options?: FitBoundsOptions, assumeSuccess?: boolean) => IDADynamicMap
  center: (coord?: LngLatLike, assumeSuccess?: boolean) => IDADynamicMap
}

interface IDAMapbox {
  init: (mapId: string, callback?: () => null) => void
  getMap: (mapId: string, assumeSuccess?: boolean) => IDADynamicMap
}

// Map Locations from twig
interface LocationEntry {
  id: number
  title: string
  lat: number
  lng: number
  color: string
  iconUrl: string
}

const animationDuration = 1000

const fitDefaults = {
  duration: animationDuration,
  padding: {
    top: 70,
    right: 40,
    bottom: 40,
    left: 40
  }
}

const impactMap = () =>
  ({
    locations: LocationEntry[],
    mapbox: window.mapbox as IDAMapbox,
    map: {} as IDADynamicMap,

    init() {
      // Wait to make sure the Mapbox Craft Plugin is loaded in the page
      if (window.mapbox === undefined) {
        document.addEventListener('DOMContentLoaded', () => {
          this.handleMapboxInit()
        })
      } else {
        this.initMap()
      }
    },

    handleMapboxInit() {
      this.mapbox = window.mapbox as IDAMapbox
      this.mapbox.init('impact-map')
      this.initMap()
    },

    initMap() {
      this.map = this.mapbox.getMap('impact-map')
      this.loadLocations()
      this.initMarkers()
    },

    loadLocations() {
      // Here are grab the same data attribute the plugins uses 
      // to store the map configuration
      const DNAString = this.map?.div?.dataset.dna
      if (DNAString) {
        const dna = JSON.parse(DNAString)
        this.locations = dna
          .filter((b: { type: string }) => b.type === 'markers')
          .map((b: { locations: LocationEntry[] }) => b.locations[0])
      }
    },

    initMarkers() {
      for (const location of this.locations) {
        // This is where the marker customization happens
        const markerEl = this.createMarkerElement(location)
        this.map?.changeMarker(location.id, {
          element: markerEl
        })
      }
    },

    createMarkerElement(location: LocationEntry): HTMLElement {
      const markerEl = document.createElement('div')
      markerEl.classList.add('marker-el')
      markerEl.style.setProperty(
        '--marker-icon-url',
        'url(' + location.iconUrl + ')'
      )

      if (location.isActive) {
        markerEl.style.setProperty(
          '--marker-bg-color',
          location.color
        )
      }

      const markerIcon = document.createElement('div')
      markerIcon.classList.add('marker-icon')

      markerEl.appendChild(markerIcon)

      markerEl.addEventListener('click', (e: Event) => {
        e.preventDefault()
        e.stopPropagation()
        this.map.closePopup('*')
        this.handleClickForLocation(location.id)
      })

      return markerEl
    },

    handleClickForLocation(locationId: number) {
      const _map = this.map._map
      const marker = this.map?.getMarker(locationId, true)
      if (_map && marker) {
        const popup = marker.getPopup()
        const isPopupOpen = popup.isOpen()

        if (!isPopupOpen) {
          marker.togglePopup()

          // Once the popup is open, calculate it's height and 
          // adjust the mapbox's map padding so the popup is centered.
          const { height } = popup
            .getElement()
            .getBoundingClientRect()

          this.fitMap(
            this.map._locationBounds([marker.getLngLat()]),
            {
              ...fitDefaults,
              padding: {
                top: height,
                bottom: 0,
                left: 0,
                right: 0
              }
            }
          )
        }
      }
    },

    fitMap(bounds?: LngLatBounds, options?: FitBoundsOptions) {
      const fitBounds = bounds || this.getMapBounds()

      const fitOptions = options || {
        ...fitDefaults,
        center: fitBounds.getCenter()
      }

      this.map._map.fitBounds(fitBounds, fitOptions)
    },

    getMapBounds() {
      return this.map._locationBounds(this.locations)
    }
  } as ImpactMapAlpineComponent)

export default impactMap

I've trimmed down the implementation here for clarity, but in the final implementation I've added related entries as categories for locations, and the color and icon is part of that category. I've added functionality to the Alipne component to show / hide locations based on selected category and adjust the map boundaries to the narrowed locations.

Overall the plugin was worth it, but I can see it's limitations unless you know Mapbox pretty well.

One thought that I had, while inspecting the dynamicmap.js file, is that since it's mostly a wrapper for mapboxgj, anywhere that it takes MapboxGL options, like mapOptions, markerOptions, or popupOptions, it could take an callback function that return those options as well, just like it's doing in the tag method.

That way we could return the options with the element option with the custom element in it. Unless, of course, I am missing something 😉.

@lindseydiloreto lindseydiloreto added the Feature Request New feature or request label Sep 20, 2023
@lindseydiloreto
Copy link
Contributor

Great news, this is now entirely possible (and relatively easy) using the element marker option...

Thanks for your workaround @webrgp, thankfully it's no longer needed. 🙂

This new feature is still only on the dev branch... would either of you mind testing it out when you get a chance?

"doublesecretagency/craft-mapbox": "dev-v1-dev"

@lindseydiloreto
Copy link
Contributor

Great news, v1.1 has been officially released with these changes. 🎉

Hope that makes working with custom marker icons a bit easier!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature Request New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants