On a Drupal 7 site, I recently solved the problem of sorting search results by distance when the things searched for have multiple locations. Since no Drupal module seems to provide this functionality, I thought I should share my solution.
An important acknowledgement: Jeff Geerling’s post, Multi-value spatial search with Solr 4.x and Drupal 7, was incredibly helpful with this work. His description of the problem and efforts to get the schema updated in the Search API Solr module were invaluable.
Background
Our client, the INTERFACE Referral Service of William James College, provides a mental health and wellness referral help line that serves residents of Massachusetts. The help line is staffed by counselors who help callers find an appropriate mental health or wellness provider. Finding an appropriate provider is a multi-faceted and, frankly, challenging process. Not only must counselors understand a caller’s needs and work out what kinds of providers could meet those needs, but they must also find an actual provider who takes the right insurance, is willing to accept a new client and has an office nearby.
Tech-Tamer, LLC helps INTERFACE maintain a large database of licensed mental health and wellness providers. The providers database includes mental health specialties, professional qualifications, insurance accepted, office locations, and roughly 50 other data points. We have built faceted search tools to help INTERFACE staff comb through this data. To date, those search tools have allowed counselors to conduct searches for providers within a given distance from a starting zip code, but the results have not been sortable by distance. INTERFACE wanted to add that capability.
If each provider had only one office, sorting by and displaying proximity information would be reasonably easy. But many providers have more than one office. When searching, counselors want to see the providers listed by their nearest office but also to see the providers’ other office locations, preferably with distance information for those as well. I could not find a module to solve this problem.
Technical Description
Each provider is a node of a particular content type. Office locations are stored in a multi-valued field provided by the Location module (an alternative to Addressfield that, at least the time of original implementation, was more reliable and full-featured). The primary search tool is a faceted, Views-based search page based on an index managed by Search API and Search API Solr.
Recent versions of Solr support proximity search on multi-value geo fields, but no Drupal module I could find takes advantage of that capability. Search API Location covers a lot of the ground, but out of the box it only works for single-value location fields provided by the Geofield module.
The following description of the solution assumes that you are reasonably familiar with the workings of Search API and Search API Solr and assumes that you have set up geocoding for your Location field. PHPDoc function headers have been stripped out to make the code listings shorter.
The Code
Use hook_search_api_data_type_info()
to add a new “location_rpt” field type labeled “Latitude/longitude (multi-value).” This will allow the new field to be indexed by Solr as a SpatialRecursivePrefixTreeFieldType (RPT for short), which is required for any spatial analysis of a multi-value field.
function MODULE_search_api_data_type_info() { return array( 'location_rpt' => array( 'name' => t('Latitude/longitude (multi-value)'), 'fallback' => 'string', 'prefix' => 'rpt', ), ); }
Both Solr and Search API Location need a latitude/longitude value expressed as a single, comma separated string — “lat,lon”. But the Location field stores separate “latitude” and “longitude” values. So we implement hook_field_info_alter()
to add an extra property callback to location fields. The property callback defines a new “Lat,Lon” property, which in turn has a getter function that returns the latitude and longitude values in the right format.
function MODULE_field_info_alter(&$field_info) { if (isset($field_info['location'])) { $field_info['location']['property_callbacks'][] = 'MODULE_field_item_location_property_callback'; } } function MODULE_field_item_location_property_callback(&$info, $entity_type, $field, $instance, $field_type) { $property =& $info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']]; $property['property info']['latlon'] = array( 'type' => 'text', 'label' => 'Lat,Lon', 'getter callback' => 'MODULE_location_latlon_get', ); } function MODULE_location_latlon_get($item, array $options, $name, $entity_type) { if ((isset($item['latitude']) && !empty($item['latitude'])) && (isset($item['longitude']) && !empty($item['longitude']))) { return $item['latitude'] . ',' . $item['longitude']; } return NULL; }
At this point we can start indexing. In the Search API index of the provider nodes, I added the related “Location” fields to the list of fields to be indexed. A new “Lat,Lon” field is now available to be indexed, and I chose to index it as “Latitude/longitude (multi-value)”. You will need to re-index after this step.
We want to leverage the Views integration capabilities of the Search API Location module. To do this I needed to patch search_api_location_get_location_fields()
so that it treats the new search api “location_rpt” field type as a location field. (The nomenclature is confusing. For this purpose the word “location” means a field that Search API Location recognizes as a geo field; I’m not talking about the composite field provided by the Location module.) You will also need to set up one or more of the geocoding options (Google, Bing, etc).
- if (!empty($field['real_type']) && search_api_extract_inner_type($field['real_type']) == 'location') { + if (!empty($field['real_type']) && substr(search_api_extract_inner_type($field['real_type']),0,8) == 'location') {
Now we can start customizing our view (this assumes you have an existing view based on the Search API index). Add an exposed filter to the view based on the new Location:Lat,Lon field. Search API Location recognizes the field as its own and provides geocoding options for the filter. Now a searcher can type an address into the exposed filter and the address will be turned into a latititude, longitude value before it is submitted to Search API as a search parameter. Label this exposed filter something like “Near to”.
We want any search performed by our modified view to sort the results by their distance from the point provided by the “Near to” filter, and to provide the actual distance. Implement hook_search_api_solr_query_alter()
to extract the filter values and modify the query appropriately.
function MODULE_search_api_solr_query_alter(array &$call_args, SearchApiQueryInterface $query) { // Example from https://cwiki.apache.org/confluence/display/solr/Spatial+Search to generate // distance as the score and sort ascending. // &q={!func}geodist()&sfield=store&pt=45.15,-93.85&sort=score+asc $options = $query->getOption('search_api_location'); if (isset($options[0])) { // Return the computed distance as the result score. // Note: this could also be {!func}rint(geodist()) to return nearest integer value // Remember that, by default, values are returned in kilometers. $call_args['params']['q'] = '{!func}geodist()'; // Identify the field to search. $call_args['params']['sfield'] = 'rptm_' . str_replace(':','$',$options[0]['field']); // Origin point. $call_args['params']['pt'] = $options[0]['lat'] . ',' . $options[0]['lon']; // Add score as the first search term. $sort_args = array('score asc'); if (isset($call_args['params']['sort'])) { $sort_args[] = $call_args['params']['sort']; } $call_args['params']['sort'] = implode(',', $sort_args); } }
When the query result comes back from Solr, the providers are sorted by the distance to their nearest office location. But I could not figure out how to get Solr to tell me which office location that was. Moreover, I wanted to show all the office locations with distances, so that I didn’t need to list the same provider multiple times. All of this calls for a new Views field handler for the new Lat,Lon field. So I added the standard hook_views_api()
call and implemented hook_views_data_alter()
to create a new pseudo field “Lat,Lon nearest” on any “search_api_index_X” tables that use the new Lat,Lon field and gave it a custom field handler.
function MODULE_search_views_data_alter(&$data) { foreach (search_api_index_load_multiple(FALSE) as $id => $index) { $table = &$data['search_api_index_' . $id]; foreach ($index->getFields() as $key => $field) { if (!empty($field['real_type']) && 'location_rpt' == search_api_extract_inner_type($field['real_type'])) { $key = _entity_views_field_identifier($key, $table); if (isset($table[$key])) { $real_field = isset($table[$key]['real field']) ? $table[$key]['real field'] : $key; $group = isset($table[$key]['group']) ? $table[$key]['group'] : NULL; $table[$key . '_nearest']['group'] = $group; $table[$key . '_nearest']['title'] = $table[$key]['title'] . ' ' . t('nearest'); $table[$key . '_nearest']['help'] = t('The nearest location.'); $table[$key . '_nearest']['real field'] = $real_field; $table[$key . '_nearest']['field']['type'] = $field['real_type']; $table[$key . '_nearest']['field']['handler'] = 'MODULEViewsHandlerFieldNearestLocation'; } } } } }
The new custom field handler extends “entity_views_handler_text,” which provides some basic plumbing. Within the handler, we can retrieve the distance calculated by Solr and the origin point for the “Near to” filter (if any). In addition, Views has already helpfully retrieved all of the office location data from the database, including latitude and longitude data. If the provider has only one location, we return the Solr distance (converted to miles and formatted as appropriate) and whatever information is desired about the location. If the provider has more than one location, things can get a bit computationally expensive. Using PHP, I compute a distance value for each location using the haversine formula. Then I sort the locations by distance and return the list of locations as an appropriately formatted string. If no origin point was specified, we do a simplified version that skips the distance rendering and sorting. This code snippet is quite long, and is reproduced at the end.
Edit the view to add the new “Lat,Lon nearest” field, and we’re done.
To Do
It would be great to turn this work into a releasable module, and I hate having to patch Search API Location to get this to work. Given time and funding, I would reach out to the maintainer of Search API Location and collaborate on adding support for Location fields and multi-location proximity search. We would also need to add more configuration options to the code so that we can meet more varied needs. In the meantime, some code samples follow.
The Views Field Handler
views\MODULE_handler_field_nearest_location.inc
/** * Specialized handler for latitude/longitude fields. */ class MODULEViewsHandlerFieldNearestLocation extends entity_views_handler_field_text { /** * {@inheritdoc} */ public function option_definition() { $options = parent::option_definition(); $options['conversion_factor'] = array('default' => '1.60926939169617'); $options['distance_suffix'] = array('default' => '', 'translatable' => TRUE); $options['precision'] = array('default' => 0); $options['decimal'] = array('default' => '.', 'translatable' => TRUE); $options['separator'] = array('default' => ',', 'translatable' => TRUE); return $options; } /** * {@inheritdoc} */ public function options_form(&$form, &$form_state) { parent::options_form($form, $form_state); $options = array(); foreach ($this->view->display_handler->get_handlers('field') as $field => $handler) { $options[$field] = $handler->ui_name(); // We only use fields up to (and including) this one. if ($field == $this->options['id']) { break; } } $form['conversion_factor'] = array( '#type' => 'select', '#title' => t('Distance unit'), '#default_value' => $this->options['conversion_factor'], '#options' => array( '1' => t('Kilometre'), '1.60926939169617' => t('Mile'), ), ); $form['distance_suffix'] = array( '#type' => 'textfield', '#title' => t('Suffix'), '#default_value' => $this->options['distance_suffix'], '#description' => t('Text to appear after the distance value, e.g. " mi"'), '#size' => 10, '#maxlength' => 32, ); $form['precision'] = array( '#type' => 'textfield', '#title' => t('Precision'), '#default_value' => $this->options['precision'], '#description' => t('Specify how many digits to print after the decimal point.'), '#size' => 2, '#maxlength' => 1, '#dependency' => array('edit-options-set-precision' => array(TRUE)), ); $form['decimal'] = array( '#type' => 'textfield', '#title' => t('Decimal point'), '#default_value' => $this->options['decimal'], '#description' => t('What single character to use as a decimal point.'), '#size' => 2, '#maxlength' => 1, '#dependency' => array('edit-options-show-distance-to-point' => array(TRUE)), ); $form['separator'] = array( '#type' => 'textfield', '#title' => t('Thousands marker'), '#default_value' => $this->options['separator'], '#description' => t('What single character to use as the thousands separator.'), '#size' => 2, '#maxlength' => 1, '#dependency' => array('edit-options-show-distance-to-point' => array(TRUE)), ); } /** * {@inheritdoc} */ public function get_value($values, $field = NULL) { // If the field is specified, return the normal value. if ($field) { return parent::get_value($values, $field); } // Get the name of the location field. $location_field = current(explode(':',$this->real_field)); // If there are no locations, return. if (!isset($values->_entity_properties['entity object']->{$location_field}[LANGUAGE_NONE])) { return NULL; } // Place the locations in a convenience variable. $locations = $values->_entity_properties['entity object']->{$location_field}[LANGUAGE_NONE]; // Create a place to hold the sorted values. $values->sorted_locations = array(); // Default values for distance and spatial filter $min_distance = $spatial_filter = NULL; // Get the relevant spatial filter, if any. $spatials = (array) $this->query->getOption('search_api_location', array()); foreach ($spatials as $spatial) { if ($spatial['field'] === $this->real_field) { $spatial_filter = $spatial; break; } } // If we have a spatial filter, sort the locations by proximity. if ($spatial_filter) { $units = $this->options['conversion_factor'] > 1 ? 'm' : 'k'; // Get value calculated by SOLR, converted if appropriate. if (isset($values->_entity_properties['search_api_relevance'])) { $min_distance = $values->_entity_properties['search_api_relevance']; // Convert to miles if appropriate. if (is_numeric($min_distance) && 'm' == $units) { $min_distance = $min_distance / $this->options['conversion_factor']; } } // If there is only one location, we can skip a lot of processing. if (1 == count($locations)) { $location = current($locations); // If a distance is not available from Solr, calculate it. if (!isset($min_distance)) { $min_distance = $this->haversine($spatial_filter['lat'], $spatial_filter['lon'], $location['latitude'], $location['longitude'], $units); } // Store the location. $values->sorted_locations[$location['lid']] = $min_distance; } // If there is more than one location, create a list sorted by distance. if (count($locations) > 1) { // Create a simple location_id => distance array. foreach ($locations as $location) { // Compute the distance. $distance = $this->haversine($spatial_filter['lat'], $spatial_filter['lon'], $location['latitude'], $location['longitude'], $units); // Store the rounded distance for each location for sorting purposes. $values->sorted_locations[$location['lid']] = round($distance,$this->options['precision']); } // Sort the array, retaining the lid keys. asort($values->sorted_locations); // Assume that SOLR is more accurate than PHP and replace the first calculated value. if (isset($min_distance)) { reset($values->sorted_locations); $nearest_lid = key($values->sorted_locations); $values->sorted_locations[$nearest_lid] = $min_distance; } } // Add address data to the sorted location(s). // This is easier to do here than in render(). if ($locations) { unset($location); foreach ($locations as $location) { $lid = $location['lid']; $values->sorted_locations[$lid] = array_merge(array('dist'=>$values->sorted_locations[$lid]), $location); } } } // No spatial filter - just put the values in the array in the order in which they appear in the db. else { foreach ($locations as $location) { $lid = $location['lid']; $values->sorted_locations[$lid] = $location; } } return $min_distance; } /** * {@inheritdoc} */ public function render($values) { // Call get_value to set up the list of locations. // We don't need the return value. $this->get_value($values); if (empty($values->sorted_locations)) { return ''; } // Simple one location format. if (count($values->sorted_locations) == 1) { return $this->location_string(current($values->sorted_locations)); } // Return multiple locations as a list. $out = ' <ul class="location-list list-unstyled">' . "\n"; foreach ( $values->sorted_locations as $location ) { $out .= ' <li>' . $this->location_string($location) . '</li> '; } $out .= "</ul> "; return $out; } /** * Turns a location array into a string. * @param array $location * @return string */ protected function location_string($location) { $out = ''; // Custom processing to render location fields and 'dist' value // into an appropriate string happens here. return $out; } /** * Distance calc using haversine formula. * * See https://developers.google.com/maps/articles/phpsqlsearch_v3. * * @param float $start_lat * @param float $start_lon * @param float $end_lat * @param float $end_lon * @param string $units - k or m, defaults to k * @return float */ protected function haversine($start_lat, $start_lon, $end_lat, $end_lon, $units = 'k') { $constant = ('k' == $units ? 6371.0 : 3959.0); return $constant * acos(cos(deg2rad($start_lat)) * cos(deg2rad($end_lat)) * cos(deg2rad($end_lon) - deg2rad($start_lon)) + sin(deg2rad($start_lat)) * sin(deg2rad($end_lat))); } }