Friday, July 10, 2015

The Plugin Trap - Tale of Google Maps Integration

WordPress plugins provide powerful features to one's WordPress site and at the same time they can introduce bugs, incompatible design elements, etc.

In this project that I am working on, we needed to have a page with an interactive map (aka. Google Maps) with a list of locations below it.

My first stab at this was to look for WordPress plugs for this.  My first criteria was to look for the plugins with large number of active installs, with good ratings, and recently updated.  I narrowed it down to:
My general findings were that:
  • Some plugins are no longer supported.
  • Many plugins offered options to support styled maps; often paid upgrades.
  • All the plugins provided an opinionated user interface for marker lists (i.e., limited styling).
  • All the plugins had complex administrative screens.
I did spend some extra time exploring WP Google Maps as it was the most promising (with the addition of the Gold version).  The key problems that I encountered, however, were:
  • The plugin had a complex administrative screen; we need to hand over the administration of the locations (markers) to the client.
  • The plugin provided an opinionated presentation of the marker list that would be incompatible without our highly styled site (deal breaker).
In the end, I opted to take a completely different approach; leveraging my background in client-side JavaScript.

1. First, I created a custom post type: https://codex.wordpress.org/Post_Types

wp-content/mu-plugins/locations.php
 
<?php
add_action( 'init', 'create_post_type' );
function create_post_type() {
  register_post_type( 'locations',
    array(
      'labels' => array(
        'name' => __( 'Locations' ),
        'singular_name' => __( 'Location' )
      ),
      'public' => false,
      'has_archive' => false,
      'show_ui' => true,
    )
  );
}

By adding this custom post type, I was able to create a top level menu in the administrative screen to manage locations that were only available through PHP code, e.g., no routes are created for viewing an individual or archive of them.

2. Created Custom Fields for Locations Posts

Because we want to show a lot of information (in addition to latitude and longitude) for each location, used the Advanced Custom Fields plugin to create a field group (show for the custom locations post type), e.g.,
  • type - list
  • latitude - text
  • longitude - text
  • continent - list
  • country - list
  • address 1 - text
  • address 2 - text
  • address 3 - text
  • phone - text
3. Created a Page with Custom Template

Create a page to display the location and a custom template for it, e.g., copy page.php to  page-locations.php

The overall strategy that I employed here is to quickly get the data out of WordPress (and PHP) and then use client-side JavaScript to build the user interface from the data.  To get the custom location posts, we use:
 
<?php
  $args = array('post_type' => 'locations');
  $loop = new WP_Query($args);
  $locations = array();
  while ($loop->have_posts()) : $loop->the_post();
    $fields = get_fields();
    $fields['title'] = get_the_title();
    $locations[] = $fields;
  endwhile;
?>

Then we can use the following to output the data as a JSON object for our JavaScript.
 
<?php echo json_encode($locations);

3. Work JavaScript Magic

Now that we have the data available on to the JavaScript on the page, we can work now in JavaScript to render the map and locations. This example was created using the jQuery JavaScript library; any more complicated I would likely want to use the more structured AngularJS JavaScript library.

note: Again, I am assuming that the reader is proficient in JavaScript.

wp-content/themes/kss/page-locations.php
 
<?php while (have_posts()) : the_post(); ?>
  <?php get_template_part('templates/page', 'header'); ?>
  <?php get_template_part('templates/content', 'page'); ?>
<?php endwhile; ?>
<?php
  $args = array(
    'post_type' => 'locations',
    'nopaging' => 'true'
  );
  $loop = new WP_Query($args);
  $locations = array();
  while ($loop->have_posts()) : $loop->the_post();
    $fields = get_fields();
    $fields['title'] = get_the_title();
    $locations[] = $fields;
  endwhile;
?>
<div id="error"></div>
<div id="map" style="width: 100%; height: 400px;"></div>
<div id="locations"></div>
<script src="https://maps.googleapis.com/maps/api/js?key=OBMITTED"></script>
<script>
  (function() {
    var i, j, k, continents, conLocations, countries, couLocations;
    var $locations = jQuery('#locations');
    var $error = jQuery('#error');
    var locations = <?php echo json_encode($locations); ?>;

    // DISPLAY MAP
    var zoom = jQuery(window).width() < 768 ? 1 : 2;
    var mapOptions = {
      center: {
        lat : 25.3130239,
        lng : -117
      },
      zoom: zoom,
      disableDefaultUI: true,
      zoomControl: true,
      zoomControlOptions: {
        style: window.google.maps.ZoomControlStyle.DEFAULT,
        position: window.google.maps.ControlPosition.BOTTOM_LEFT
      },
      scaleControl: false,
      styles:
[{"featureType":"administrative","elementType":"labels.text.fill","stylers":[{"color":"#444444"}]},{"featureType":"landscape","elementType":"all","stylers":[{"color":"#f2f2f2"}]},{"featureType":"poi","elementType":"all","stylers":[{"visibility":"off"}]},{"featureType":"road","elementType":"all","stylers":[{"saturation":-100},{"lightness":45}]},{"featureType":"road.highway","elementType":"all","stylers":[{"visibility":"simplified"}]},{"featureType":"road.arterial","elementType":"labels.icon","stylers":[{"visibility":"off"}]},{"featureType":"transit","elementType":"all","stylers":[{"visibility":"off"}]},{"featureType":"water","elementType":"all","stylers":[{"color":"#46bcec"},{"visibility":"on"}]}]
  };
    var map = new window.google.maps.Map(window.document.getElementById('map'),
      mapOptions);

    // DISPLAY MARKERS
    for (i = 0; i < locations.length; i++) {
      locations[i].lat = Number(locations[i].lat);
      locations[i].lng = Number(locations[i].lng);
      try {
        new window.google.maps.Marker({
          position: locations[i],
          map: map
        });
      } catch(err) {
        $error.append('location with invalid latitude or longitude');
        return;
      }
    }

    // CONTINENTS
    var detail = [];
    detail.push('<ul class="continents">');
    continents = locations.map(getContinent);
    continents = jQuery.grep(continents, isFirstContinent).sort();
    for (i = 0; i < continents.length; i++) {
      detail.push('<li>');
      detail.push('<div class="continent-title">' + htmlEncode(continents[i]) + '</div>');
      conLocations = locations.filter(withContinent, i);

      // COUNTRIES
      countries = conLocations.map(getCountry);
      countries = jQuery.grep(countries, isFirstCountry).sort();
      detail.push('<ul class="countries">');
      for (j = 0; j < countries.length; j++) {
        detail.push('<li>');
        detail.push('<div class="country-title">' + htmlEncode(countries[j]) + '</div>');

        // LOCATIONS
        couLocations = conLocations.filter(withCountry, j);
        detail.push('<ul class="locations">');
        for (k = 0; k < couLocations.length; k++) {
          if (!/^[a-zA-Z]+$/.test(couLocations[k].type)) {
            $error.append('location with invalid type');
            return;
          }
          detail.push('<li class="' + htmlEncode(couLocations[k].type) + '">');
          detail.push('<div class="location-title">' + htmlEncode(couLocations[k].title) + '</div>');
          if (couLocations[k].address_1) {
            detail.push('<div class="address">' + htmlEncode(couLocations[k].address_1) + '</div>');
          }
          if (couLocations[k].address_2) {
            detail.push('<div class="address">' + htmlEncode(couLocations[k].address_2) + '</div>');
          }
          if (couLocations[k].address_3) {
            detail.push('<div class="address">' + htmlEncode(couLocations[k].address_3) + '</div>');
          }
          if (couLocations[k].phone) {
            detail.push('<div class="phone">' + htmlEncode(couLocations[k].phone) + '</div>');
          }
          detail.push('</li>');
        }
        detail.push('</ul>');
        detail.push('</li>');
      }
      detail.push('</ul>');
      detail.push('</li>');
    }
    detail.push('</ul>');
    $locations.append(detail.join('\n'));
    function htmlEncode(text) {
      return jQuery('<div/>').text(text).html();
    }
    function getContinent(location) {
      return location.continent;
    }
    function isFirstContinent(element, index) {
      return jQuery.inArray(element, continents) === index;
    }
    function withContinent(element) {
      return element.continent === continents[this];
    }
    function getCountry(location) {
      return location.country;
    }
    function isFirstCountry(element, index) {
      return jQuery.inArray(element, countries) === index;
    }
    function withCountry(element) {
      return element.country === countries[this];
    }
  })();
</script>

No comments:

Post a Comment