Faire des requêtes géolocalisées avec WP_Query

Il m’arrive fréquemment, pour le développement d’annuaires ou de sites de services, de devoir charger des contenus en fonction de coordonnées GPS qui leur sont associés.

Cette fonctionnalité peut servir, par exemple dans le cas d’un annuaire, à afficher des prestataires proches du domicile du visiteur ; dans le cas d’une agence immobilière à ressortir des biens dans la localité souhaitée par l’utilisateur ; dans le cas d’un site de presse à afficher des actualités concernant le secteur géographique de l’internaute, etc.

Dans cet article, je vais vous guider dans la mise en place d’un bout de code qui vous permettra de personnaliser WP_query, afin de récupérer des contenus (des biens immobiliers, des prestataires…) en fonction d’un point géographique de référence. Ce dernier pourra être, par exemple, les coordonnées GPS du visiteur de votre site.

Afin de mettre en pratique ce tutoriel, il faut savoir comment récupérer les coordonnées d’un internaute ainsi qu’assigner une position à ses articles.

Les requêtes géolocalisées dans WordPress
Comment faire des requêtes de distances, sur les tables des articles et des utilisateurs

Une requête SQL de recherche géolocalisée

Un peu de trigonométrie ! Dans chacune des situations citées plus haut, nous souhaitons trouver la distance qui sépare deux points : les coordonnées associées a un contenu à celles qui sont liées à l’utilisateur du site – que ce soit via un formulaire ou autre…

Voici la fonction mathématique qui permet de calculer l’orthodromie, à savoir l’arc de cercle le plus court entre deux points – ici à l’échelle de la Terre :

Rayon de la Terre * acos( cos( latitude1 ) * cos( latitude2 )  * cos( longitude2 – longitude1 ) + sin( latitude1 ) * sin( latitude2 ) )

Grâce au précédent tutoriel nous avons conçu une metabox qui permet d’assigner des coordonnées GPS à un contenu. Nous allons pouvoir comparer ces valeurs, enregistrées en tant que meta, à celles renseignées par l’internaute. Voici la requête SQL qui nous permet de récupérer la distance qui les sépare.


SET @lat_user = 48.85; #latitude à comparer
SET @lng_user = 2.35; #longitude à comparer
SET @rayon_terre = 6371; # en km

SELECT wp_posts.*, ( @rayon_terre * acos( cos( radians( @lat_user ) ) 
* cos( radians( metalat.meta_value ) ) 
* cos( radians( metalng.meta_value ) - radians( @lng_user ) ) 
+ sin( radians( @lat_user ) ) 
* sin( radians( metalat.meta_value ) ) ) ) AS distance 
FROM wp_posts

INNER JOIN wp_postmeta AS metalat 
ON ( wp_posts.ID = metalat.post_id ) 

INNER JOIN wp_postmeta AS metalng 
ON ( wp_posts.ID = metalng.post_id ) 

WHERE 1=1 
AND wp_posts.post_type = 'post' #type de contenu
AND metalat.meta_key = 'lat' #clé de la meta latitude
AND metalng.meta_key = 'lng' #clé de la meta longitude

Cette requête, si vous l’exécutez sur votre base de données, vous renvoie tous les articles ayant des meta lat et lng, agrémenté d’une nouvelle colonne distance, indiquant la distance qui sépare vos articles du lieu ciblé.

Étendre la WP_Query grâce aux hooks

Voilà que commence la partie intéressante de cet article : nous allons voir comment les hooks de WP_Query permettent de modeler la requête de WordPress. En effet, après que les query_vars n’aient été parsées et au fur et à mesure que le système compose la requête SQL, nous avons la possibilité d’agir sur celle-ci par le biais de quelques filtres :

Il existe de nombreux autres filtres disponibles. Si vous souhaitez en savoir plus vous pouvez directement lire le code source… Vous y verrez que ces filtres sont systématiquement désactivés lorsque la valeur du paramètre suppress_filters est true, comme c’est le cas par défaut dans la fonction get_posts.

Pour altérer la requête nous allons passer de nouveaux arguments à WP Query. Ceux-ci seront considérés par WordPress comme des query_vars et seront accessibles dans nos fonctions de filtrage.

<?php
// Exemple de requête Géolocalisée
$test = new WP_Query( array( 
			'post_type' => 'post', 
			'geo_query' => array( 
				'lat'      => 1.45,
				'lng'      => 1.42, 
				'distance' => 200, // en km
				'compare'  => '<=' // par défaut
				) 
		) );

Dès lors, la variable de requête geo_query peut être utilisée dans les filtres que nous allons mettre en place.

Commençons par ajouter la colonne distance, grâce au filtre posts_fields :

add_filter( 'posts_fields', 'willy_geo_fields', 10, 2 );
function willy_geo_fields( $fields, $q ) {
	if ( isset( $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] ) ) {

		global $wpdb;
		$fields .= $wpdb->prepare(", ( 6371 * acos( cos( radians( %f ) ) 
						* cos( radians( metalat.meta_value ) ) 
						* cos( radians( metalng.meta_value ) - radians( %f ) ) 
						+ sin( radians( %f ) ) 
						* sin( radians( metalat.meta_value ) ) ) ) AS distance",
		              $q->query_vars['geo_query']['lat'],
					  $q->query_vars['geo_query']['lng'],
					  $q->query_vars['geo_query']['lat'] );
	}
	return $fields;
}

Si les variables requises sont disponibles, je viens ajouter, juste après wp_posts.*, le calcul de l’orthodromie que je renvoie en tant que «distance».  On emploie la méthode prepare afin de vérifier que les valeurs insérées dans la requête seront conformes au format attendu – avec ici des %f pour float.

Puis nous allons faire les jointures SQL grâce au hook posts_join :

add_filter( 'posts_join', 'willy_geo_join', 10, 2 );
function willy_geo_join( $join, $q) {
	if ( isset( $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] ) ) {
		global $wpdb;
		$join .= " INNER JOIN $wpdb->postmeta AS metalat ON ( $wpdb->posts.ID = metalat.post_id )";
		$join .= " INNER JOIN $wpdb->postmeta AS metalng ON ( $wpdb->posts.ID = metalng.post_id )";
	}
	return $join;
}

Ensuite il faut utiliser le filtre posts_where pour dire que les meta des coordonnées ont pour clés lat et lng

add_filter( 'posts_where', 'willy_geo_where', 10, 2 );
function willy_geo_where( $where, $q ) {
	if ( isset( $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] ) ) {

		global $wpdb;
		$where .= " AND metalat.meta_key = 'lat'";
		$where .= " AND metalng.meta_key = 'lng'";
	}
	return $where;
}

Et pour finir, nous faisons appel au hook posts_groupby pour cibler les articles qui nous intéressent.

Ce que l’on souhaite ici, c’est ressortir uniquement ceux dont la distance est inférieure ou égale à 200 km de notre point de référence. Un WHERE ne pourrait pas fonctionner car «distance» est une valeur calculée, c’est pour cela que nous allons utiliser la clause HAVING qui dépend de GROUP BY.

add_filter( 'posts_groupby', 'willy_geo_distance', 10, 2 );
function willy_geo_distance( $groupby, $q ) {
	if ( isset( $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] ) ) {

		$compare = '<=';
		if ( isset( $q->query_vars['geo_query']['compare'] )
		  && in_array( $q->query_vars['geo_query']['compare'], array( '<', '<=', '>', '>=' ) ) ) {
		  	$compare = $q->query_vars['geo_query']['compare'];
		}
		global $wpdb;
		$groupby .= $wpdb->prepare( "$wpdb->posts.ID HAVING distance $compare %d",
					  $q->query_vars['geo_query']['distance'] );
	}
	return $groupby;
}

Les lignes que j’ai mis en évidence servent à assigner un opérateur de comparaison par défaut, ainsi qu’à filtrer ceux qui sont autorisés.

Et voilà ! En déposant ces quelques filtres dans votre fichier fonction vous avez activé la possibilité de faire des requêtes géolocalisées, que ce soit par le biais d’une nouvelle WP_Query, ou bien en passant par un filtre tel que pre_get_posts.

Trier les résultats en fonction de la proximité

Il manque tout de même une fonctionnalité à notre système : comment filtrer les articles renvoyés selon la distance…

Par défaut, les paramètres order et orderby des requêtes WordPress n’accèptent que certaines valeurs. Mais vous vous en doutez bien, il existe un filtre pour changer cela. Il s’appelle posts_orderby.

add_filter( 'posts_orderby', 'willy_geo_orderby_distance', 10, 2 );
function willy_geo_orderby_distance( $orderby, $q ) {
	if ( isset( $q->query_vars['orderby'],
		  $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] )
	  && 'distance' == $q->query_vars['orderby'] ) {

		$order = in_array( $q->query_vars['order'], array( 'ASC', 'DESC' ) ) ? $q->query_vars['order'] : 'ASC';
		$orderby = 'distance ' . $order;
	}
	return $orderby;
}

Si la requête en cours contient une geo_query, et que la valeur du paramètre orderby est distance, alors les résultats de la WP_Query seront triés en fonction de leur distance par rapport au point de référence.

Des requêtes géolocalisées pour les users WordPress

Le système est à 100 % opérationnel pour faire des requêtes géolocalisées de vos contenus. En revanche si vous souhaitez utiliser la même fonctionnalité sur la base des utilisateurs – par exemple dans le cadre d’un annuaire de membres – il vous faudra du code supplémentaire. Cela est dû au fait que WP_User_Query et WP_Query sont des classes différentes qui ne partagent pas les mêmes filtres ou méthodes.

Pour concevoir des requêtes par coordonnées GPS sur la table wp_users, il faut mettre en place un filtre via le hook pre_user_query. Ce hook est différent de ceux que nous avons vus plus haut car il filtre, par référence, l’ensemble de l’objet de la requête. Il est moins fourni donc et il faut ruser un peu pour parvenir à nos fins.

Voici le code à mettre en place :

add_filter( 'pre_user_query', 'willy_geo_users' );
function willy_geo_users( $q ) {
	if ( isset( $q->query_vars['geo_query']['lat'],
		  $q->query_vars['geo_query']['lng'],
		  $q->query_vars['geo_query']['distance'] ) ) {

		// Opérateur de comparaison
		$compare = '<=';
		if ( isset( $q->query_vars['geo_query']['compare'] )
		  && in_array( $q->query_vars['geo_query']['compare'], array( '<', '<=', '>', '>=' ) ) ) {
		  	$compare = $q->query_vars['geo_query']['compare'];
		}

		global $wpdb;

		// Colonne distance
		$q->query_fields .= $wpdb->prepare(", ( 6371 * acos( cos( radians( %f ) ) 
						* cos( radians( metalat.meta_value ) ) 
						* cos( radians( metalng.meta_value ) - radians( %f ) ) 
						+ sin( radians( %f ) ) 
						* sin( radians( metalat.meta_value ) ) ) ) AS distance",
					  $q->query_vars['geo_query']['lat'],
					  $q->query_vars['geo_query']['lng'],
					  $q->query_vars['geo_query']['lat'] );

		// Jointures
		$q->query_from .= " INNER JOIN $wpdb->usermeta AS metalat ON ( $wpdb->users.ID = metalat.user_id )";
		$q->query_from .= " INNER JOIN $wpdb->usermeta AS metalng ON ( $wpdb->users.ID = metalng.user_id )";

		$q->query_where .= " AND metalat.meta_key = 'lat'";
		$q->query_where .= " AND metalng.meta_key = 'lng'";

		// Order by
		if ( 'distance' == $q->query_vars['orderby'] 
		  && in_array( $q->query_vars['order'], array( 'ASC', 'DESC' ) ) ) {
			$q->query_orderby = 'ORDER BY distance ' . $q->query_vars['order'];
		}

		// Group by et Having
		$q->query_orderby = $wpdb->prepare( "GROUP BY $wpdb->users.ID HAVING distance $compare %d ",
					  $q->query_vars['geo_query']['distance'] ) . $q->query_orderby;

	}
}

Ici nous pouvons filtrer uniquement query_fields, query_from, query_where et query_orderby. Il faut donc coller nos clauses GROUP BY et HAVING au tout début de ORDER BY… C’est une petite pirouette mais ça fonctionne 😀

De la même manière que pour WP_Query, vous pouvez maintenant passer des paramètres geo_query à l’intérieur d’une WP_User_Query. Il est également possible de trier vos utilisateurs selon leur distance d’un point GPS !

C’est fini pour ce long tutoriel qui je l’espère vous sera utile. N’hésitez pas à me faire part de vos remarques, et à m’envoyer un petit message si vous le mettez en pratique sur un de vos projets 🙂

Contacter l'auteur :

willy bahuaud

Je suis Willy bahuaud, je développe des plugins WordPress de géolocalisation. J'interviens aussi sur la conception de site WordPress pour des clients qui ont besoin de géolocaliser leurs contenus : annuaires, store locator, agendas événementiels…
Si vous souhaitez que je vous accompagne sur un projet contactez-moi !

5 commentaires

  1. Par michael — Il y a 2 années
    Bonjour,
    Là je dis bravo et cela va bien m’aider (surtout pour les users..)
    Je vais tenter de le mettre en pratique sur un site sportif afin de fournir aux utilisateurs des partenaires dans leur secteur de pratique.
    Encore merci, tiens d’ailleurs je tweets….
  2. Par Spidlace — Il y a 2 années
    Super tutoriel ! Je vais pouvoir tester tout ça sur un prochain projet et c’est une chose dont j’aurais sans doute besoin. 🙂

    Merci Willy ;-)

  3. Par David — Il y a 2 années
    J’ai presque envie de dire que ça tombe à pic !
    Mais dans mon cas, ça tombe avec quelques semaines de retard …

    J’ai déjà bricolé mon affaire avec de belles requêtes SQL perso.
    toutefois, je pensais faire une sorte de V2 un peu plus propre et sous forme de plugin, et en essayant d’être moins dépendant d’autres ressources ( gmap … ).
    Je risque donc de m’inspirer de tes travaux.

    Quelques questions :
    Pourquoi 1=1 dans ta premiere requête sql ?
    Ne serait-ce pas plus simple de passer par $wpdb->get_results($querystr, OBJECT); via une seule requête optimisé ?
    et en passant par une procedure stocké ?

    Merci.

  4. Par Willy Bahuaud — Il y a 2 années
    C’est sont deux bonnes questions 🙂

    • La raison pour laquelle la requête contient WHERE 1=1 est que je me suis basé sur la requête de base effectué par WP_Query – le but étant, à la fin, d’intégrer le système dans WordPress, c’était juste un requête «pour voir». WordPress met un clause WHERE 1=1 tout simplement pour que les autres développeurs puisse rajouter des clauses en commençant par « AND… » sans se soucier du WHERE ;
    • à la deuxième question : oui nous pourrions passer par une requête SQL via get_results(), mais ce n’est pas le but. Le but est de donner la possibilité d’utiliser les requêtes géolocalisées de façon natives. Ainsi, par exemple, on peut trier/filter les résultats d’une page d’archive sans requête supplémentaire, en passant par pre_get_posts. De cela découle deux choses : on ne perd aucune fonctionnalité de WordPress en route (paginations, sitemaps, hooks…) et on permet aux autres plugins de se greffer sur le système.

     😀

    , Je suis content que cet article vous intéresse et j’espère qu’il vous sera utile dans vos projets. N’hésitez pas à venir partager les liens lorsqu’ils seront finis !

  5. Par Antoine Brossault — Il y a 2 années
    Article très intéressant comme toujours.
    Je ne suis pas certain d’avoir un besoin immédiat sur ce genre de chose, mais savoir que c’est possible avec WordPress c’est top 🙂

    Ça élargit la réflexion et le champ des possibilités.

Commenter