ACF Google Maps Radius / Proximity search with the REST API

Published:

Let’s discuss and show how using ACF Google Maps, and WP_Query we can search content by radius and/or proximity using both latitude and longitude set through ACF Google Maps.

File set up:

  • Lets create three seperate files ( these will be included inside your functions.php in order )
  • sort-acf-gmap-distance.php
  • wp-query-geo.php
  • sort-locations-api.php

Let’s do a quick review of what the following will consist of:

Sort ACF Gmap Distance

This file will contain a function where we send the address to be geocoded, and will then return that locations lat and lng. It also saves the them as separate meta fields.

WP Query GEO

This file will contains multiple functions and notes.  Here is a post that describes how it all works a little more.  The point is that it provides another query option, geo_query.  With this we can direct queries to the DB and search specifically for lat/lng will still doing normal a meta_query, etc.  Essentially you aren’t limited to just that new query when filtering content.

Sort Locations API

This file will contain our newly created API endpoint that uses all of the above and outputs the desired results.  This will also include pagination.

That all sound good? Then lets roll!

sort-acf-gmap-distancy.php

<?php 

// returns longitude and latitude from a location
function fancysquares_get_lat_and_lng($origin){
    $api_key = "<your-api-key>";
    $url = "https://maps.googleapis.com/maps/api/geocode/json?address=".urlencode($origin)."&key=".$api_key;
    $result_string = file_get_contents($url);
    $result = json_decode($result_string, true);
    $result1[]=$result['results'][0];

    return $result1[0];
}
// update unique meta field with ACF Google Map LAT / LNG
function acf_save_update_latlng($post_id, $post, $update) {

	$map = get_post_meta($post_id, 'your_map', true);

	if (!empty($map)) {
	    update_post_meta( $post_id, 'flat_lat', $map['lat'] );
	    update_post_meta( $post_id, 'flat_lng', $map['lng'] );
	}

}
add_action('save_post', 'acf_save_update_latlng', 90, 3);

// run after ACF saves the $_POST['acf'] data
add_action('acf/save_post', 'acf_save_lat_lng', 20);

wp-query-geo.php

Please see here for additional references from their comments:

<?php if(!defined('ABSPATH')) { die(); } // Include in all php files, to prevent direct execution
/**
 * Plugin Name: WP Geo Query
 * Plugin URI: https://gschoppe.com/wordpress/location-searches/
 * Description: Adds location search support to WP_Query, making it easy to create completely custom "Find Location" pages.
 * Author: Greg Schoppe
 * Author URI: https://gschoppe.com
 * Version: 1.0.0
 **/
 
if( !class_exists('GJSGeoQuery') ) {
    class GJSGeoQuery {
        public static function Instance() {
            static $instance = null;
            if ($instance === null) {
                $instance = new self();
            }
            return $instance;
        }
 
        private function __construct() {
            add_filter( 'posts_fields' , array( $this, 'posts_fields'  ), 10, 2 );
            add_filter( 'posts_join'   , array( $this, 'posts_join'    ), 10, 2 );
            add_filter( 'posts_where'  , array( $this, 'posts_where'   ), 10, 2 );
            add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
        }
 
        // add a calculated "distance" parameter to the sql query, using a haversine formula
        public function posts_fields( $sql, $query ) {
            global $wpdb;
            $geo_query = $query->get('geo_query');
            if( $geo_query ) {
 
                if( $sql ) {
                    $sql .= ', ';
                }
                $sql .= $this->haversine_term( $geo_query ) . " AS geo_query_distance";
            }
            return $sql;
        }
 
        public function posts_join( $sql, $query ) {
            global $wpdb;
            $geo_query = $query->get('geo_query');
            if( $geo_query ) {
 
                if( $sql ) {
                    $sql .= ' ';
                }
                $sql .= "INNER JOIN " . $wpdb->prefix . "postmeta AS geo_query_lat ON ( " . $wpdb->prefix . "posts.ID = geo_query_lat.post_id ) ";
                $sql .= "INNER JOIN " . $wpdb->prefix . "postmeta AS geo_query_lng ON ( " . $wpdb->prefix . "posts.ID = geo_query_lng.post_id ) ";
            }
            return $sql;
        }
 
        // match on the right metafields, and filter by distance
        public function posts_where( $sql, $query ) {
            global $wpdb;
            $geo_query = $query->get('geo_query');
            if( $geo_query ) {
                $lat_field = 'latitude';
                if( !empty( $geo_query['lat_field'] ) ) {
                    $lat_field =  $geo_query['lat_field'];
                    // print_r($lat_field);
                    // die;
                }
                $lng_field = 'longitude';
                if( !empty( $geo_query['lng_field'] ) ) {
                    $lng_field =  $geo_query['lng_field'];
                }
                $distance = 20;
                if( isset( $geo_query['distance'] ) ) {
                    $distance = $geo_query['distance'];
                    // print_r($distance);
                    // die;
                }
                if( $sql ) {
                    $sql .= " AND ";
                }
                $haversine = $this->haversine_term( $geo_query );
                $new_sql = "( geo_query_lat.meta_key = %s AND geo_query_lng.meta_key = %s AND " . $haversine . " <= %f )";
                $sql .= $wpdb->prepare( $new_sql, $lat_field, $lng_field, $distance );
            }
            return $sql;
        }
 
        // handle ordering
        public function posts_orderby( $sql, $query ) {
            $geo_query = $query->get('geo_query');
            if( $geo_query ) {
                $orderby = $query->get('orderby');
                $order   = $query->get('order');
                if( $orderby == 'distance' ) {
                    if( !$order ) {
                        $order = 'ASC';
                    }
                    $sql = 'geo_query_distance ' . $order;
                }
            }
            return $sql;
        }
 
        public static function the_distance( $post_obj = null, $round = false ) {
            echo self::get_the_distance( $post_obj, $round );
        }
 
        public static function get_the_distance( $post_obj = null, $round = false ) {
            global $post;
            if( !$post_obj ) {
                $post_obj = $post;
            }
            if( property_exists( $post_obj, 'geo_query_distance' ) ) {
                $distance = $post_obj->geo_query_distance;
                if( $round !== false ) {
                    $distance = round( $distance, $round );
                }
                return $distance;
            }
            return false;
        }
 
        private function haversine_term( $geo_query ) {
            global $wpdb;
            $units = "miles";
            if( !empty( $geo_query['units'] ) ) {
                $units = strtolower( $geo_query['units'] );
            }
            $radius = 3959;
            if( in_array( $units, array( 'km', 'kilometers' ) ) ) {
                $radius = 6371;
            }
            $lat_field = "geo_query_lat.meta_value";
            $lng_field = "geo_query_lng.meta_value";
            $lat = 0;
            $lng = 0;
            if( isset( $geo_query['latitude'] ) ) {
                $lat = $geo_query['latitude' ];
            }
            if(  isset( $geo_query['longitude'] ) ) {
                $lng = $geo_query['longitude'];
            }
            $haversine  = "( " . $radius . " * ";
            $haversine .=     "acos( cos( radians(%f) ) * cos( radians( " . $lat_field . " ) ) * ";
            $haversine .=     "cos( radians( " . $lng_field . " ) - radians(%f) ) + ";
            $haversine .=     "sin( radians(%f) ) * sin( radians( " . $lat_field . " ) ) ) ";
            $haversine .= ")";
            $haversine  = $wpdb->prepare( $haversine, array( $lat, $lng, $lat ) );
            return $haversine;
        }
    }
    GJSGeoQuery::Instance();
}
 
if( !function_exists( 'the_distance' ) ) {
    function the_distance( $post_obj = null, $round = false ) {
        GJSGeoQuery::the_distance( $post_obj, $round );
    }
}
 
if( !function_exists( 'get_the_distance' ) ) {
    function get_the_distance( $post_obj = null, $round = false ) {
        return GJSGeoQuery::get_the_distance( $post_obj, $round );
    }
}

 

sort-locations-api.php

<?php

/**
 * Register the /wp-json/fancysquares/v1/vandelay_industries endpoint
 */

/**
 * get vandelay_industries by location radius, closest within 10 files to what you entered
*/
class FANCY_SQUARES_MAP_RESULTS_API_ENDPOINT extends WP_REST_Controller {
    /**
     * Constructor.
     */
    public function __construct() {
        $this->namespace = 'fancysquares/v1';
        $this->rest_base = 'vandelay_industries';
    }

    /**
     * Register the component routes.
     */
    public function register_routes() {
        //region added
        register_rest_route( $this->namespace, '/' . $this->rest_base , array(
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => array( $this, 'vandelay_industries_locations' ),
                'permission_callback' => array( $this, 'vandelay_industries_locations_permissions_check' ),
                'args'                => $this->get_collection_params(),
            )
        ) );

    }
    
    /**
     * Retrieve vandelay industries locations
     */
    public function vandelay_industries_locations( $request ) {
        
        $origin = $request['location'];
        $proximity = $request['proximity'];

        $foundCity = '';
        //$foundState = '';
        $lat1 = '';
        $lng1 = '';
        
        if($origin && ($usingLocation === 'yes')) {
            
            $origin_coords = fancysquares_get_lat_and_lng($origin);
            // find city and state
            for ($j=0;$j<count($origin_coords['address_components']);$j++) {
                $cn=array($origin_coords['address_components'][$j]['types'][0]);
                if (in_array("locality", $cn)) {
                    $foundCity = $origin_coords['address_components'][$j]['long_name'];
                }
            }

            $lat1 = $origin_coords['geometry']['location']['lat'];
            $lng1 = $origin_coords['geometry']['location']['lng'];	
        
            $args = array(
                'update_post_term_cache' => false,
                'post_type' 		=> 'vandelay_industries',
                'posts_per_page' 	=> $request['per_page'],
                'paged'           	=> $request['page'],
                'geo_query' => array(
                    'lat_field' => 'flat_lat',  // this is the name of the meta field storing latitude
                    'lng_field' => 'flat_lng', // this is the name of the meta field storing longitude 
                    'latitude'  => $lat1,    // this is the latitude of the point we are getting distance from
                    'longitude' => $lng1,   // this is the longitude of the point we are getting distance from
                    'distance'  => $proximity,    // this is the maximum distance to search
                    'units'     => 'miles'       // this supports options: miles, mi, kilometers, km
                ),
                'meta_query' => array(
                ),
                'orderby' => 'distance', // this tells WP Query to sort by distance
                'order'   => 'ASC'
            );

            // print_r('sorted');
            // die;

        } else {

            $args = array(
                'update_post_term_cache' => false,
                'post_type' => 'vandelay_industries',
                'posts_per_page' => $request['per_page'],
                'paged' => $request['page'],
                'meta_query' => array(
                )
            );

            // print_r('not');
            // die;
        }

        // use WP_Query to get the results with pagination
        $query = new WP_Query( $args ); 

        // if no posts found return 
        if( empty($query->posts) ){
            return new WP_Error( 'no_posts', __('No post found'), array( 'status' => 404 ) );
        }

        // set max number of pages and total num of posts
        $posts = $query->posts;
        
        $max_pages = $query->max_num_pages;
        $total = $query->found_posts;

        foreach ( $posts as $post ) {
            $response = $this->prepare_item_for_response( $post, $request );
            $data[] = $this->prepare_response_for_collection( $response );  
        }
        
        // set headers and return response      
        $response = new WP_REST_Response($data, 200);

        if($foundCity){
            // $updatedAddress = $foundCity .', '.$foundState;
            $response->header( 'GEO-Address', $foundCity );
            $response->header( 'GEO-Lat', $lat1 );
            $response->header( 'GEO-Lng', $lng1 );
        }

        $response->header( 'X-WP-Total', $total ); 
        $response->header( 'X-WP-TotalPages', $max_pages );
        
        // return data/response
        return $response;
    }
    
    /**
     * Check if a given request has access to post items.
     */
    public function vandelay_industries_locations_permissions_check( $request ) {
        return true;
    }
    
    /**
     * Get the query params for collections
     */
    public function get_collection_params() {
        return array(
            'page'     => array(
                'description'       => 'Current page of the collection.',
                'type'              => 'integer',
                'default'           => 1,
                'sanitize_callback' => 'absint',
            ),
            'per_page' => array(
                'description'       => 'Maximum number of items to be returned in result set.',
                'type'              => 'integer',
                'default'           => 6,
                'sanitize_callback' => 'absint',              
            ),
        );
    }
    
    /**
     * Prepares post data for return as an object.
     */
    public function prepare_item_for_response( $post, $request ) {
        // get card id
        $postID = $post->ID;
        // meta fields notes:
        /*
            * get_post_custom returned ALL META, we only want ACF
            * get_fields is a unique helper function to ACF (only gets Meta Fields done by ACF)
            * returns array of ALL ACF fields
            * if empty, should return null by default per field
            *
            * was previously using get_post_meta() for the fields individually
            * this would be the way to go if the cards had more meta fields that had nothing to do with the API data returned / example: custom page builder for the front-end
        */
        $acfFields = get_fields($postID);
        // get card title - post title - default field
        $postTitle = $post->post_title;
        // link
        $permalink = get_permalink($postID);
        // featured image
        $featuredImage = get_the_post_thumbnail_url($postID,'medium-large');


        $data = array(
            'id' 				=> $postID,
            'title' 			=> $postTitle,
            'link'				=> $permalink,
            'featured_image'	=> $featuredImage,
        );
    
        return $data;
    }
}


add_action( 'rest_api_init', function () {
    $controller = new FANCY_SQUARES_MAP_RESULTS_API_ENDPOINT();
    $controller->register_routes();
} );

 

And finally, and example AJAX call that would bring it all together.

// get variables from url to pass to api
function setApiData(miles) {
        const miles = setMiles;
    const paramsPage = 1
    const paramsPerPage = 6
    return {
                location: 'address and/or locaitons goes here',
        proximity: miles,
        per_page: paramsPerPage,
        page: paramsPage,
    };
}

function getDoctors(setMiles) {
    axios.get('/wp-json/fancysquares/v1/vandelay_industries', {
        params: setApiData
    })
        .then((response) => {
            // handle success, do what you want!
            console.log(response);
        })
        .catch((error) => {
            // handle error
            console.log(error.message);
        })
        .then(() => {
            // always executed
        });
}

export {
    getDoctors
};

 

A special thanks to the following resources where I put this all together from ( for the radius and location filtering )

I encourage your to try both of the above links as just standard WP_Query calls to get a better idea of what’s going on before implementing it with the REST API.