WordPress Map with ACF, Leaflet, and OpenStreetMaps

September 18, 2022 12 minutes
WordPress Map with ACF, Leaflet, and OpenStreetMaps

Preface

Do you want to show points of interest, like store locations, real estate listings, and events around a city, on a WordPress website, without using Google Maps? For this article, I have prepared a tutorial for the tech-savvy and fellow developers on how to do so.

Note: You can also visit the Related Repository and Demo Site to get an idea of what we will be covering and what is the end result.

Let’s go through why it’s important to use Maps, especially if you’re a local business, why would you like to stick with Google Maps or choose a free alternative like OpenStreetMaps.

Then we will go through step by step on some sample code, that lives in a custom theme for this example, to showcase what is possible using:

Let’s get this rolling.

Why would you want to use Maps on your website?

Many websites can benefit from a map feature, the store locator case being the most obvious. It helps with discoverability, drives conversions, and builds trust and brand.

There are many other cases where you would want to implement such a feature, for instance, a website or app for a Conference may display related events or a website of a Real Estate Agent would display positions of the properties. The possibilities are endless.

The Google Maps API is capable of all the above with a major drawback. It’s not free, although it used to be.

In this tutorial we will try to relate events to their respective locations on a map, using a free alternative.

Free Maps with the Leaflet JavaScript Library and OpenStreetMaps

Leaflet is one of those open-source API alternatives for Google Maps that has become quite popular because of its modern JavaScript data library and complete freedom to customize. Of course, Leaflet is not an actual mapping service, but it works great with OpenStreetMaps and it is the favorite solution for developers who prefer open-source products.

Leaflet Pros

  • Open-source and free API with a JavaScript data library
  • Fully customizable
  • Compatible with other mapping API services

Leaflet Cons

  • It requires a developer (or at least some developing skills) to get started and use it
  • It needs third-party services to support some configurations.

An example implementation

An example implementation using the famous WordPress ACF Plugin and the Leaflet Library in a Custom Post Type.

To showcase the procedure of implementing such a feature with the Leaflet library in our WordPress website we will use a WordPress Child Theme, with custom post types, where we want to show locations on a map, either on the archive page or the single post page. We will make the scripts and settings flexible, so it can be used also in the default post type, or even a page.

In this setting, we will work on our custom theme, rather than building a dedicated plugin, which is out of the scope of this current article.

Step 1: Prepare your WordPress Installation

To start, locate the theme you are going to work on within your folders. It should be something like this: /app/public/wp-content/themes/your-theme-name, in my settings it’s /app/public/wp-content/themes/twentytwenty-child.

Now, if we are going to use our script in a custom post type, we better have something set up. You can create custom post types in numerous ways, but in my case, I will write code and place it under /app/public/wp-content/mu-plugins.

In this folder create a file named custom-post-types.php (or name it accordingly) and insert this sample code:

Contents of /app/public/wp-content/mu-plugins/custom-post-types.php:

<?php

function wordpress_acf_leaflet_create_custom_post_types()
{
    //Event Post Type
    $event_labels = array(
        'name' => __('Events'),
        'singular_name' => __('Event'),
        'all_items' => __('All Events'),
        'add_new' => _x('Add new Event', 'Event'),
        'add_new_item' => __('Add new Event'),
        'edit_item' => __('Edit Event'),
        'new_item' => __('New Event'),
        'view_item' => __('View Event'),
        'search_items' => __('Search in Events'),
        'not_found' =>  __('No Events found'),
        'not_found_in_trash' => __('No Events found in trash'),
        'parent_item_colon' => ''
    );

    $event_args = array(
        'labels' => $event_labels,
        'public' => true,
        'show_in_rest' => true,
        'has_archive' => true,
        'menu_icon' => 'dashicons-calendar',
        'rewrite' => array('slug' => 'events'),
        'query_var' => true,
        'menu_position' => 5,
        'supports'    => array('excerpt', 'title', 'editor', 'thumbnail')
    );

    register_post_type('event', $event_args);

}

add_action('init', 'wordpress_acf_leaflet_create_custom_post_types');

Step 2: Advanced Custom Fields

Install and activate Advanced Custom Fields for WordPress Developers in your WordPress installation. For what we will use it, you can use the free version.

Important Note: You should update the permalinks structure after this step. Go to Settings — Permalinks and click on Save Changes

Now in your theme folder create an inc folder and, inside that, a php file, let’s say location-acf.php.

Contents of app/public/wp-content/themes/twentytwenty-child/inc/location-acf.php:

<?php

if( function_exists('acf_add_local_field_group') ):

acf_add_local_field_group(array(
	'key' => 'group_631ee0fc4e8ec',
	'title' => 'Location',
	'fields' => array(
		array(
			'key' => 'field_631ee108ff573',
			'label' => 'Location Address',
			'name' => 'location_address',
			'type' => 'text',
			'instructions' => '',
			'required' => 0,
			'conditional_logic' => 0,
			'wrapper' => array(
				'width' => '',
				'class' => '',
				'id' => '',
			),
			'default_value' => '',
			'placeholder' => '',
			'prepend' => '',
			'append' => '',
			'maxlength' => '',
		),
		array(
			'key' => 'field_631ee1ceff574',
			'label' => 'Location Latitude',
			'name' => 'location_latitude',
			'type' => 'number',
			'instructions' => '',
			'required' => 0,
			'conditional_logic' => 0,
			'wrapper' => array(
				'width' => '',
				'class' => '',
				'id' => '',
			),
			'default_value' => '',
			'placeholder' => '',
			'prepend' => '',
			'append' => '',
			'min' => -90,
			'max' => 90,
			'step' => '0.01',
		),
		array(
			'key' => 'field_631ee278ff575',
			'label' => 'Location Longitude',
			'name' => 'location_longitude',
			'type' => 'number',
			'instructions' => '',
			'required' => 0,
			'conditional_logic' => 0,
			'wrapper' => array(
				'width' => '',
				'class' => '',
				'id' => '',
			),
			'default_value' => '',
			'placeholder' => '',
			'prepend' => '',
			'append' => '',
			'min' => -180,
			'max' => 180,
			'step' => '0.01',
		),
	),
	'location' => array(
		array(
			array(
				'param' => 'post_type',
				'operator' => '==',
				'value' => 'event',
			),
		),
		array(
			array(
				'param' => 'post_type',
				'operator' => '==',
				'value' => 'post',
			),
		),
		array(
			array(
				'param' => 'page_template',
				'operator' => '==',
				'value' => 'default',
			),
		),
	),
	'menu_order' => 0,
	'position' => 'normal',
	'style' => 'default',
	'label_placement' => 'top',
	'instruction_placement' => 'label',
	'hide_on_screen' => '',
	'active' => true,
	'description' => '',
	'show_in_rest' => 1,
));

endif;		

The last step is to require the above file in your functions.php, doing like so:

<?php
//Require Custom Location Files
require_once( get_stylesheet_directory(). '/inc/location-acf.php' );

Now you should be able to, not only see the custom post type event, but also insert Location information, like address and the Latitude and Longitude, or in other words the coordinates at the geographic coordinate system. As per the above settings the Pages and Posts will also have this option. I have done this intentionally, so you can experiment with the scripts.

Step 3: Create a Custom API Route

For this example, we will use an optimized custom endpoint to get the information to build our maps. Learn more about Adding Custom Endpoints on the official documentation.

Again, in the inc folder create a php file named maps-route.php.

Contents of app/public/wp-content/themes/twentytwenty-child/inc/maps-route.php:

<?php

add_action('rest_api_init', 'demoRegisterMaps');

function demoRegisterMaps()
{
    register_rest_route('demo/v1', 'maps', array(
        'methods' => WP_REST_Server::READABLE,
        'callback' => 'query_posts_and_pages_with_params'
    ));
};

function query_posts_and_pages_with_params(WP_REST_Request $request)
{
    $args = array();
    $args['post_type'] = $request->get_param('type');
    $args['id'] = $request->get_param('id');

    return query_posts_and_pages($args);
}

function query_posts_and_pages($args)
{

    if ($args['post_type'] == 'page' && !$args['id']) {

        $query_args = array(
            'page_id' => $args['id'],
            'post_type' => $args['post_type'],
            'nopaging' => true,
            'order' => 'ASC',
            'orderby' => 'title',
        );
    } elseif ($args['post_type'] == 'page' && $args['id']) {
        $query_args = array(
            'page_id' => $args['id'],
            'nopaging' => true,
            'order' => 'ASC',
            'orderby' => 'title',
        );
    } elseif ($args['post_type'] != 'page' && !$args['id']) {
        $query_args = array(
            'post_type' => $args['post_type'],
            'nopaging' => true,
            'order' => 'ASC',
            'orderby' => 'title',
        );
    } elseif ($args['post_type'] != 'page' && $args['id']) {
        $query_args = array(
            'p' => $args['id'],
            'post_type' => $args['post_type'],
            'nopaging' => true,
            'order' => 'ASC',
            'orderby' => 'title',
        );
    }


    return fetchByPostTypeAndID($query_args);
}

function fetchByPostTypeAndID($query_args)
{
    // Run a custom query
    $meta_query = new WP_Query($query_args);
    if ($meta_query->have_posts()) {
        //Define an empty array
        $data = array();
        // Store each post's data in the array
        while ($meta_query->have_posts()) {
            $meta_query->the_post();

            if (get_field('location_latitude') && get_field('location_longitude')) {
                $post_object = (object) [
                    'id' => get_the_ID(),
                    'title' => (object) ['rendered' => get_the_title()],
                    'link' => get_the_permalink(),
                    'location' => (object) [
                        'latitude' => get_field('location_latitude'),
                        'longitude' => get_field('location_longitude'),
                        'address' => get_field('location_address')
                    ]
                ];
                $data[] = $post_object;
            }
        }
        // Return the data
        return $data;
    } else {
        // If there is no post
        return new WP_Error('rest_not_found', esc_html__('Error fetching data.', 'twentytwentytwochild'), array('status' => 404));
    }
}

The last step is to require the above file in your functions.php, doing like so:

<?php
//Require Custom Location Files
require_once( get_stylesheet_directory(). '/inc/location-acf.php' );
require_once( get_stylesheet_directory(). '/inc/maps-route.php' );

Now you should be able to query and experiment with this endpoint using software like PostMan.

For instance, the response for my setup, using dummy data, for this route https://wordpress-acf-leaflet-demo.elissavet.me/wp-json/demo/v1/maps/?type=event&id=8 (choosing a GET) is

[
    {
        "id": 8,
        "title": {
            "rendered": "Athens Art Book Fair 2022"
        },
        "link": "https://wordpress-acf-leaflet-demo.elissavet.me/events/athens-art-book-fair-2022/",
        "location": {
            "latitude": "37.973256",
            "longitude": "23.743341",
            "address": "The Athens Conservatory, Rigillis & 17-19 Vasileos Georgiou B, Pangrati, 106 75"
        }
    }
]

and the JSON returned for the route https://wordpress-acf-leaflet-demo.elissavet.me/wp-json/demo/v1/maps/?type=event is the whole set of events:

[
    {
        "id": 8,
        "title": {
            "rendered": "Athens Art Book Fair 2022"
        },
        "link": "https://wordpress-acf-leaflet-demo.elissavet.me/events/athens-art-book-fair-2022/",
        "location": {
            "latitude": "37.973256",
            "longitude": "23.743341",
            "address": "The Athens Conservatory, Rigillis & 17-19 Vasileos Georgiou B, Pangrati, 106 75"
        }
    },
    {
        "id": 7,
        "title": {
            "rendered": "Illustradays 2022"
        },
        "link": "https://wordpress-acf-leaflet-demo.elissavet.me/events/illustradays-2022%ef%bf%bc/",
        "location": {
            "latitude": "37.9732091",
            "longitude": "23.7009461",
            "address": "Serafeio Athletics & Community Complex, 160 Pireos, Rouf, 118 54"
        }
    },
    {
        "id": 9,
        "title": {
            "rendered": "Technopolis Vinyl Market"
        },
        "link": "https://wordpress-acf-leaflet-demo.elissavet.me/events/technopolis-vinyl-market/",
        "location": {
            "latitude": "37.97",
            "longitude": "23.71",
            "address": "Technopolis, 100 Pireos, Gazi, 118 54"
        }
    }
]

To be more flexible in our setup, or maybe even call custom pin graphics from our theme’s images folder, we need to pass some variables from the backend to the frontend.

This can be accomplished using the wp_localize_script.

Now add this in functions.php:


function custom_demo_files()
{
    wp_enqueue_script('main-demo-js', get_theme_file_uri('/build/index.js'), array(), '1.0', true);

    wp_localize_script('main-demo-js', 'php_to_js', [
        'data' => array(
            'theme_uri' => get_theme_file_uri(),
            'root_url' => get_site_url()
        )
    ]);

    wp_enqueue_style('demo_main_style', get_theme_file_uri('/build/style-index.css'));
    wp_enqueue_style('demo_extra_style', get_theme_file_uri('/build/index.css'));


};

add_action('wp_enqueue_scripts', 'custom_demo_files');

Step 5: Prepare your Javascript Setup

To start working with the javascript source javascript files, (located in your theme folder under src) use the below code in the file package.json (you can edit the name to match your preference), located in the root of your theme’s folder.

Note: You should have Node.js and npm installed. If you haven’t already you could visit this guide.

Contents of app/public/wp-content/themes/twentytwenty-child/package.json:

{
    "name": "fictional-demo-theme",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
      "build": "wp-scripts build",
      "start": "wp-scripts start",
      "dev": "wp-scripts start",
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "Elissavet Triantafyllopoulou",
    "license": "ISC",
    "dependencies": {
      "@wordpress/scripts": "^24.0.0",
      "axios": "^0.27.2",
      "leaflet": "^1.8.0"
    }
}

Open up the terminal and run npm-install

After that, you should try to start the development server with npm run dev or perform a one-time operation with npm run build

Step 6: Front-end - Your Javascript and CSS Files

Contents of app/public/wp-content/themes/twentytwenty-child/src/modules/LeafletMap.js:


import L from "leaflet";
import axios from "axios"

class LeafletMap {
    constructor() {
        document.querySelectorAll(".leaflet-map").forEach(el => {
            el.post_type = !('template' in el.dataset) ? 'post' : el.getAttribute("data-template") //template keeps the post-type
            el.post_id = !('id' in el.dataset) ? '' : el.getAttribute("data-id")
            this.new_map(el)
        })
    }

    new_map(el) {
        let get_url = `${php_to_js.data.root_url}/wp-json/demo/v1/maps?type=${el.post_type}${el.post_id ? `&id=${el.post_id}` : ''}`
        let that = this
        let arrayOfLatLngs = []
        let showLink = false
        const args = {
            zoom: 14,
            centerLat: 0.0,
            centerLng: 0.0,
        }

        const map = L.map(el).setView([args.centerLng, args.centerLng], args.zoom);

        L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
            maxZoom: 19,
            attribution: '© OpenStreetMap'
        }).addTo(map)

        axios.get(get_url)
            .then(function (response) {
                // handle success
                console.log(response); //left intentionally here
                if (Array.isArray(response.data)) {
                    if (response.data.length > 1) {
                        showLink = true
                    }else {
                        showLink = false
                    }
                    response.data.forEach(element => {
                        if (element.location.latitude !== '' && element.location.longitude !== '') {
                            arrayOfLatLngs.push([element.location.latitude, element.location.longitude])
                            that.add_marker(map, element, showLink)
                        }
                    });

                }
                // center map
                that.center_map(map, arrayOfLatLngs)
                
            })
            .catch(function (error) {
                // handle error
                //Show a custom error Map
                //Console error
                console.log(error);
            })

    } // end new_map

    add_marker(map, element, showLink) {

        const templateUrl = php_to_js.data.theme_uri

        const myIcon = L.icon({
            iconUrl: templateUrl + "/images/maps/icon.png",
            iconRetinaUrl: templateUrl + '/images/maps/icon-retina.png',
        })

        const { latitude, longitude, address } = element.location

        const marker = new L.Marker([latitude, longitude], { icon: myIcon })

        marker.addTo(map)

        marker.bindPopup(`
            ${`<p class="popup-title">${showLink? `<a href="${element.link}">` : ''}${element.title.rendered}${showLink? '</a>' : ''}</p>`}
            ${address ? `<p class="popup-address">${address}</p>` : ''}        
        `)


    } // end add_marker

    center_map(map, arrayOfLatLngs) {
        var bounds = new L.LatLngBounds(arrayOfLatLngs);

        // only 1 marker?
        if (arrayOfLatLngs.length == 1) {
            // set center of map
            map.setView(arrayOfLatLngs[0], 16, { animation: true });
        } else {
            // fit to bounds
            map.fitBounds(bounds).setZoom(map.getZoom() - 1);
        }
    } // end center_map

}

export default LeafletMap

Contents of wp-content/themes/twentytwenty-child/src/index.js:

import "../css/style.scss"

// Our modules / classes
import LeafletMap from "./modules/LeafletMap"

// Instantiate a new object using our modules/classes
var leafletMap  = new LeafletMap()

Contents of /wp-content/themes/twentytwenty-child/css/style.scss:

@import "../node_modules/leaflet/dist/leaflet.css";

.acf-map {
    width: 100%;
    height: 500px;
    border: #ccc solid 1px;
    font-size: 18px;
    margin: 3rem auto;

    @media (max-width: 640px) {
        height: 320px;
    }

    p {
        font-size: 1.5rem;
    }

    .popup-title {
        color: #cd2653;
    }

    .popup-address {
        font-weight: bolder;
    }
}

Step 7: Call the Map’s HTML in your theme PHP files

Now that you hopefully have everything set up, you will need to output a div with certain characteristics to your theme files, to show the map and the related marker or markers.

In general, the script expects the div to have a class of “leaflet-map” and 2 data attributes:

  1. data-template that should hold our post-type
  2. data-id which should be used to query only the related location of our post

If you want to display only one marker, in our case a single event site, this code would go on single-event.php:

 <!-- Example usage on pages -->
<?php if (get_field('location_latitude') && get_field('location_longitude')) : ?>
   <!-- example usage on page or custom post-type / single point of interest marker -->
   <hr class="section-break">
   <h2 class="headline headline--small">Location of <?php the_title(); ?></h2>
   <div class="acf-map leaflet-map" data-id=<?php the_ID(); ?> data-template=<?php echo get_post_type() ?>></div>
<?php endif; ?>

Or if you want to show the list of all events in archive-event.php you would:

 <!-- example usage on custom post-type multiple points of interest markers -->
<hr class="section-break">
<h2 class="headline headline--small">Find Sites On The Map</h2>
<div class="acf-map leaflet-map" data-template=<?php echo get_post_type() ?>></div>

or you could even hard-code it like so:

<div class="acf-map leaflet-map" data-template="event"></div>

Conclusion

In this article, we saw how maps can be used in a website and their importance on the User Experience.

Then we experimented with an actual implementation, with the free and open-source Leaflet Javascript Library implementing it in a custom WordPress Theme.

Visit the Related Repository and Demo Site, to see what we’ve covered and the end result.