]> git.openstreetmap.org Git - nominatim.git/blob - lib/Geocode.php
Merge remote-tracking branch 'upstream/master'
[nominatim.git] / lib / Geocode.php
1 <?php
2
3 namespace Nominatim;
4
5 require_once(CONST_BasePath.'/lib/PlaceLookup.php');
6 require_once(CONST_BasePath.'/lib/ReverseGeocode.php');
7
8 class Geocode
9 {
10     protected $oDB;
11
12     protected $aLangPrefOrder = array();
13
14     protected $bIncludeAddressDetails = false;
15     protected $bIncludeExtraTags = false;
16     protected $bIncludeNameDetails = false;
17
18     protected $bIncludePolygonAsPoints = false;
19     protected $bIncludePolygonAsText = false;
20     protected $bIncludePolygonAsGeoJSON = false;
21     protected $bIncludePolygonAsKML = false;
22     protected $bIncludePolygonAsSVG = false;
23     protected $fPolygonSimplificationThreshold = 0.0;
24
25     protected $aExcludePlaceIDs = array();
26     protected $bDeDupe = true;
27     protected $bReverseInPlan = true;
28
29     protected $iLimit = 20;
30     protected $iFinalLimit = 10;
31     protected $iOffset = 0;
32     protected $bFallback = false;
33
34     protected $aCountryCodes = false;
35     protected $aNearPoint = false;
36
37     protected $bBoundedSearch = false;
38     protected $aViewBox = false;
39     protected $sViewboxCentreSQL = false;
40     protected $sViewboxSmallSQL = false;
41     protected $sViewboxLargeSQL = false;
42
43     protected $iMaxRank = 20;
44     protected $iMinAddressRank = 0;
45     protected $iMaxAddressRank = 30;
46     protected $aAddressRankList = array();
47     protected $exactMatchCache = array();
48
49     protected $sAllowedTypesSQLList = false;
50
51     protected $sQuery = false;
52     protected $aStructuredQuery = false;
53
54
55     public function __construct(&$oDB)
56     {
57         $this->oDB =& $oDB;
58     }
59
60     public function setReverseInPlan($bReverse)
61     {
62         $this->bReverseInPlan = $bReverse;
63     }
64
65     public function setLanguagePreference($aLangPref)
66     {
67         $this->aLangPrefOrder = $aLangPref;
68     }
69
70     public function getIncludeAddressDetails()
71     {
72         return $this->bIncludeAddressDetails;
73     }
74
75     public function getIncludeExtraTags()
76     {
77         return $this->bIncludeExtraTags;
78     }
79
80     public function getIncludeNameDetails()
81     {
82         return $this->bIncludeNameDetails;
83     }
84
85     public function setIncludePolygonAsPoints($b = true)
86     {
87         $this->bIncludePolygonAsPoints = $b;
88     }
89
90     public function setIncludePolygonAsText($b = true)
91     {
92         $this->bIncludePolygonAsText = $b;
93     }
94
95     public function setIncludePolygonAsGeoJSON($b = true)
96     {
97         $this->bIncludePolygonAsGeoJSON = $b;
98     }
99
100     public function setIncludePolygonAsKML($b = true)
101     {
102         $this->bIncludePolygonAsKML = $b;
103     }
104
105     public function setIncludePolygonAsSVG($b = true)
106     {
107         $this->bIncludePolygonAsSVG = $b;
108     }
109
110     public function setPolygonSimplificationThreshold($f)
111     {
112         $this->fPolygonSimplificationThreshold = $f;
113     }
114
115     public function setLimit($iLimit = 10)
116     {
117         if ($iLimit > 50) $iLimit = 50;
118         if ($iLimit < 1) $iLimit = 1;
119
120         $this->iFinalLimit = $iLimit;
121         $this->iLimit = $iLimit + min($iLimit, 10);
122     }
123
124     public function getExcludedPlaceIDs()
125     {
126         return $this->aExcludePlaceIDs;
127     }
128
129     public function getViewBoxString()
130     {
131         if (!$this->aViewBox) return null;
132         return $this->aViewBox[0].','.$this->aViewBox[3].','.$this->aViewBox[2].','.$this->aViewBox[1];
133     }
134
135     public function setFeatureType($sFeatureType)
136     {
137         switch ($sFeatureType) {
138             case 'country':
139                 $this->setRankRange(4, 4);
140                 break;
141             case 'state':
142                 $this->setRankRange(8, 8);
143                 break;
144             case 'city':
145                 $this->setRankRange(14, 16);
146                 break;
147             case 'settlement':
148                 $this->setRankRange(8, 20);
149                 break;
150         }
151     }
152
153     public function setRankRange($iMin, $iMax)
154     {
155         $this->iMinAddressRank = $iMin;
156         $this->iMaxAddressRank = $iMax;
157     }
158
159     public function setRoute($aRoutePoints, $fRouteWidth)
160     {
161         $this->aViewBox = false;
162
163         $this->sViewboxCentreSQL = "ST_SetSRID('LINESTRING(";
164         $sSep = '';
165         foreach ($aRoutePoints as $aPoint) {
166             $fPoint = (float)$aPoint;
167             $this->sViewboxCentreSQL .= $sSep.$fPoint;
168             $sSep = ($sSep == ' ') ? ',' : ' ';
169         }
170         $this->sViewboxCentreSQL .= ")'::geometry,4326)";
171
172         $this->sViewboxSmallSQL = 'st_buffer('.$this->sViewboxCentreSQL;
173         $this->sViewboxSmallSQL .= ','.($fRouteWidth/69).')';
174
175         $this->sViewboxLargeSQL = 'st_buffer('.$this->sViewboxCentreSQL;
176         $this->sViewboxLargeSQL .= ','.($fRouteWidth/30).')';
177     }
178
179     public function setViewbox($aViewbox)
180     {
181         $this->aViewBox = array_map('floatval', $aViewbox);
182
183         $fHeight = $this->aViewBox[0] - $this->aViewBox[2];
184         $fWidth = $this->aViewBox[1] - $this->aViewBox[3];
185         $aBigViewBox[0] = $this->aViewBox[0] + $fHeight;
186         $aBigViewBox[2] = $this->aViewBox[2] - $fHeight;
187         $aBigViewBox[1] = $this->aViewBox[1] + $fWidth;
188         $aBigViewBox[3] = $this->aViewBox[3] - $fWidth;
189
190         $this->sViewboxCentreSQL = false;
191         $this->sViewboxSmallSQL = "ST_SetSRID(ST_MakeBox2D(ST_Point(".$this->aViewBox[0].",".$this->aViewBox[1]."),ST_Point(".$this->aViewBox[2].",".$this->aViewBox[3].")),4326)";
192         $this->sViewboxLargeSQL = "ST_SetSRID(ST_MakeBox2D(ST_Point(".$aBigViewBox[0].",".$aBigViewBox[1]."),ST_Point(".$aBigViewBox[2].",".$aBigViewBox[3].")),4326)";
193     }
194
195     public function setNearPoint($aNearPoint, $fRadiusDeg = 0.1)
196     {
197         $this->aNearPoint = array((float)$aNearPoint[0], (float)$aNearPoint[1], (float)$fRadiusDeg);
198     }
199
200     public function setQuery($sQueryString)
201     {
202         $this->sQuery = $sQueryString;
203         $this->aStructuredQuery = false;
204     }
205
206     public function getQueryString()
207     {
208         return $this->sQuery;
209     }
210
211
212     public function loadParamArray($oParams)
213     {
214         $this->bIncludeAddressDetails
215          = $oParams->getBool('addressdetails', $this->bIncludeAddressDetails);
216         $this->bIncludeExtraTags
217          = $oParams->getBool('extratags', $this->bIncludeExtraTags);
218         $this->bIncludeNameDetails
219          = $oParams->getBool('namedetails', $this->bIncludeNameDetails);
220
221         $this->bBoundedSearch = $oParams->getBool('bounded', $this->bBoundedSearch);
222         $this->bDeDupe = $oParams->getBool('dedupe', $this->bDeDupe);
223
224         $this->setLimit($oParams->getInt('limit', $this->iFinalLimit));
225         $this->iOffset = $oParams->getInt('offset', $this->iOffset);
226
227         $this->bFallback = $oParams->getBool('fallback', $this->bFallback);
228
229         // List of excluded Place IDs - used for more acurate pageing
230         $sExcluded = $oParams->getStringList('exclude_place_ids');
231         if ($sExcluded) {
232             foreach ($sExcluded as $iExcludedPlaceID) {
233                 $iExcludedPlaceID = (int)$iExcludedPlaceID;
234                 if ($iExcludedPlaceID)
235                     $aExcludePlaceIDs[$iExcludedPlaceID] = $iExcludedPlaceID;
236             }
237
238             if (isset($aExcludePlaceIDs))
239                 $this->aExcludePlaceIDs = $aExcludePlaceIDs;
240         }
241
242         // Only certain ranks of feature
243         $sFeatureType = $oParams->getString('featureType');
244         if (!$sFeatureType) $sFeatureType = $oParams->getString('featuretype');
245         if ($sFeatureType) $this->setFeatureType($sFeatureType);
246
247         // Country code list
248         $sCountries = $oParams->getStringList('countrycodes');
249         if ($sCountries) {
250             foreach ($sCountries as $sCountryCode) {
251                 if (preg_match('/^[a-zA-Z][a-zA-Z]$/', $sCountryCode)) {
252                     $aCountries[] = strtolower($sCountryCode);
253                 }
254             }
255             if (isset($aCountries))
256                 $this->aCountryCodes = $aCountries;
257         }
258
259         $aViewbox = $oParams->getStringList('viewboxlbrt');
260         if ($aViewbox) {
261             $this->setViewbox($aViewbox);
262         } else {
263             $aViewbox = $oParams->getStringList('viewbox');
264             if ($aViewbox) {
265                 $this->setViewBox(array(
266                                    $aViewbox[0],
267                                    $aViewbox[3],
268                                    $aViewbox[2],
269                                    $aViewbox[1]
270                                   ));
271             } else {
272                 $aRoute = $oParams->getStringList('route');
273                 $fRouteWidth = $oParams->getFloat('routewidth');
274                 if ($aRoute && $fRouteWidth) {
275                     $this->setRoute($aRoute, $fRouteWidth);
276                 }
277             }
278         }
279     }
280
281     public function setQueryFromParams($oParams)
282     {
283         // Search query
284         $sQuery = $oParams->getString('q');
285         if (!$sQuery) {
286             $this->setStructuredQuery(
287                 $oParams->getString('amenity'),
288                 $oParams->getString('street'),
289                 $oParams->getString('city'),
290                 $oParams->getString('county'),
291                 $oParams->getString('state'),
292                 $oParams->getString('country'),
293                 $oParams->getString('postalcode')
294             );
295             $this->setReverseInPlan(false);
296         } else {
297             $this->setQuery($sQuery);
298         }
299     }
300
301     public function loadStructuredAddressElement($sValue, $sKey, $iNewMinAddressRank, $iNewMaxAddressRank, $aItemListValues)
302     {
303         $sValue = trim($sValue);
304         if (!$sValue) return false;
305         $this->aStructuredQuery[$sKey] = $sValue;
306         if ($this->iMinAddressRank == 0 && $this->iMaxAddressRank == 30) {
307             $this->iMinAddressRank = $iNewMinAddressRank;
308             $this->iMaxAddressRank = $iNewMaxAddressRank;
309         }
310         if ($aItemListValues) $this->aAddressRankList = array_merge($this->aAddressRankList, $aItemListValues);
311         return true;
312     }
313
314     public function setStructuredQuery($sAmentiy = false, $sStreet = false, $sCity = false, $sCounty = false, $sState = false, $sCountry = false, $sPostalCode = false)
315     {
316         $this->sQuery = false;
317
318         // Reset
319         $this->iMinAddressRank = 0;
320         $this->iMaxAddressRank = 30;
321         $this->aAddressRankList = array();
322
323         $this->aStructuredQuery = array();
324         $this->sAllowedTypesSQLList = '';
325
326         $this->loadStructuredAddressElement($sAmentiy, 'amenity', 26, 30, false);
327         $this->loadStructuredAddressElement($sStreet, 'street', 26, 30, false);
328         $this->loadStructuredAddressElement($sCity, 'city', 14, 24, false);
329         $this->loadStructuredAddressElement($sCounty, 'county', 9, 13, false);
330         $this->loadStructuredAddressElement($sState, 'state', 8, 8, false);
331         $this->loadStructuredAddressElement($sPostalCode, 'postalcode', 5, 11, array(5, 11));
332         $this->loadStructuredAddressElement($sCountry, 'country', 4, 4, false);
333
334         if (sizeof($this->aStructuredQuery) > 0) {
335             $this->sQuery = join(', ', $this->aStructuredQuery);
336             if ($this->iMaxAddressRank < 30) {
337                 $sAllowedTypesSQLList = '(\'place\',\'boundary\')';
338             }
339         }
340     }
341
342     public function fallbackStructuredQuery()
343     {
344         if (!$this->aStructuredQuery) return false;
345
346         $aParams = $this->aStructuredQuery;
347
348         if (sizeof($aParams) == 1) return false;
349
350         $aOrderToFallback = array('postalcode', 'street', 'city', 'county', 'state');
351
352         foreach ($aOrderToFallback as $sType) {
353             if (isset($aParams[$sType])) {
354                 unset($aParams[$sType]);
355                 $this->setStructuredQuery(@$aParams['amenity'], @$aParams['street'], @$aParams['city'], @$aParams['county'], @$aParams['state'], @$aParams['country'], @$aParams['postalcode']);
356                 return true;
357             }
358         }
359
360         return false;
361     }
362
363     public function getDetails($aPlaceIDs)
364     {
365         //$aPlaceIDs is an array with key: placeID and value: tiger-housenumber, if found, else -1
366         if (sizeof($aPlaceIDs) == 0) return array();
367
368         $sLanguagePrefArraySQL = "ARRAY[".join(',', array_map("getDBQuoted", $this->aLangPrefOrder))."]";
369
370         // Get the details for display (is this a redundant extra step?)
371         $sPlaceIDs = join(',', array_keys($aPlaceIDs));
372
373         $sImportanceSQL = '';
374         if ($this->sViewboxSmallSQL) $sImportanceSQL .= " case when ST_Contains($this->sViewboxSmallSQL, ST_Collect(centroid)) THEN 1 ELSE 0.75 END * ";
375         if ($this->sViewboxLargeSQL) $sImportanceSQL .= " case when ST_Contains($this->sViewboxLargeSQL, ST_Collect(centroid)) THEN 1 ELSE 0.75 END * ";
376
377         $sSQL = "select osm_type,osm_id,class,type,admin_level,rank_search,rank_address,min(place_id) as place_id, min(parent_place_id) as parent_place_id, calculated_country_code as country_code,";
378         $sSQL .= "get_address_by_language(place_id, -1, $sLanguagePrefArraySQL) as langaddress,";
379         $sSQL .= "get_name_by_language(name, $sLanguagePrefArraySQL) as placename,";
380         $sSQL .= "get_name_by_language(name, ARRAY['ref']) as ref,";
381         if ($this->bIncludeExtraTags) $sSQL .= "hstore_to_json(extratags)::text as extra,";
382         if ($this->bIncludeNameDetails) $sSQL .= "hstore_to_json(name)::text as names,";
383         $sSQL .= "avg(ST_X(centroid)) as lon,avg(ST_Y(centroid)) as lat, ";
384         $sSQL .= $sImportanceSQL."coalesce(importance,0.75-(rank_search::float/40)) as importance, ";
385         $sSQL .= "(select max(p.importance*(p.rank_address+2)) from place_addressline s, placex p where s.place_id = min(CASE WHEN placex.rank_search < 28 THEN placex.place_id ELSE placex.parent_place_id END) and p.place_id = s.address_place_id and s.isaddress and p.importance is not null) as addressimportance, ";
386         $sSQL .= "(extratags->'place') as extra_place ";
387         $sSQL .= "from placex where place_id in ($sPlaceIDs) ";
388         $sSQL .= "and (placex.rank_address between $this->iMinAddressRank and $this->iMaxAddressRank ";
389         if (14 >= $this->iMinAddressRank && 14 <= $this->iMaxAddressRank) $sSQL .= " OR (extratags->'place') = 'city'";
390         if ($this->aAddressRankList) $sSQL .= " OR placex.rank_address in (".join(',', $this->aAddressRankList).")";
391         $sSQL .= ") ";
392         if ($this->sAllowedTypesSQLList) $sSQL .= "and placex.class in $this->sAllowedTypesSQLList ";
393         $sSQL .= "and linked_place_id is null ";
394         $sSQL .= "group by osm_type,osm_id,class,type,admin_level,rank_search,rank_address,calculated_country_code,importance";
395         if (!$this->bDeDupe) $sSQL .= ",place_id";
396         $sSQL .= ",langaddress ";
397         $sSQL .= ",placename ";
398         $sSQL .= ",ref ";
399         if ($this->bIncludeExtraTags) $sSQL .= ",extratags";
400         if ($this->bIncludeNameDetails) $sSQL .= ",name";
401         $sSQL .= ",extratags->'place' ";
402
403         if (30 >= $this->iMinAddressRank && 30 <= $this->iMaxAddressRank) {
404             // only Tiger housenumbers and interpolation lines need to be interpolated, because they are saved as lines
405             // with start- and endnumber, the common osm housenumbers are usually saved as points
406             $sHousenumbers = "";
407             $i = 0;
408             $length = count($aPlaceIDs);
409             foreach ($aPlaceIDs as $placeID => $housenumber) {
410                 $i++;
411                 $sHousenumbers .= "(".$placeID.", ".$housenumber.")";
412                 if ($i<$length) $sHousenumbers .= ", ";
413             }
414             if (CONST_Use_US_Tiger_Data) {
415                 // Tiger search only if a housenumber was searched and if it was found (i.e. aPlaceIDs[placeID] = housenumber != -1) (realized through a join)
416                 $sSQL .= " union";
417                 $sSQL .= " select 'T' as osm_type, place_id as osm_id, 'place' as class, 'house' as type, null as admin_level, 30 as rank_search, 30 as rank_address, min(place_id) as place_id, min(parent_place_id) as parent_place_id, 'us' as country_code";
418                 $sSQL .= ", get_address_by_language(place_id, housenumber_for_place, $sLanguagePrefArraySQL) as langaddress ";
419                 $sSQL .= ", null as placename";
420                 $sSQL .= ", null as ref";
421                 if ($this->bIncludeExtraTags) $sSQL .= ", null as extra";
422                 if ($this->bIncludeNameDetails) $sSQL .= ", null as names";
423                 $sSQL .= ", avg(st_x(centroid)) as lon, avg(st_y(centroid)) as lat,";
424                 $sSQL .= $sImportanceSQL."-1.15 as importance ";
425                 $sSQL .= ", (select max(p.importance*(p.rank_address+2)) from place_addressline s, placex p where s.place_id = min(blub.parent_place_id) and p.place_id = s.address_place_id and s.isaddress and p.importance is not null) as addressimportance ";
426                 $sSQL .= ", null as extra_place ";
427                 $sSQL .= " from (select place_id";
428                 // interpolate the Tiger housenumbers here
429                 $sSQL .= ", ST_LineInterpolatePoint(linegeo, (housenumber_for_place-startnumber::float)/(endnumber-startnumber)::float) as centroid, parent_place_id, housenumber_for_place";
430                 $sSQL .= " from (location_property_tiger ";
431                 $sSQL .= " join (values ".$sHousenumbers.") as housenumbers(place_id, housenumber_for_place) using(place_id)) ";
432                 $sSQL .= " where housenumber_for_place>=0 and 30 between $this->iMinAddressRank and $this->iMaxAddressRank) as blub"; //postgres wants an alias here
433                 $sSQL .= " group by place_id, housenumber_for_place"; //is this group by really needed?, place_id + housenumber (in combination) are unique
434                 if (!$this->bDeDupe) $sSQL .= ", place_id ";
435             }
436             // osmline
437             // interpolation line search only if a housenumber was searched and if it was found (i.e. aPlaceIDs[placeID] = housenumber != -1) (realized through a join)
438             $sSQL .= " union ";
439             $sSQL .= "select 'W' as osm_type, place_id as osm_id, 'place' as class, 'house' as type, null as admin_level, 30 as rank_search, 30 as rank_address, min(place_id) as place_id, min(parent_place_id) as parent_place_id, calculated_country_code as country_code, ";
440             $sSQL .= "get_address_by_language(place_id, housenumber_for_place, $sLanguagePrefArraySQL) as langaddress, ";
441             $sSQL .= "null as placename, ";
442             $sSQL .= "null as ref, ";
443             if ($this->bIncludeExtraTags) $sSQL .= "null as extra, ";
444             if ($this->bIncludeNameDetails) $sSQL .= "null as names, ";
445             $sSQL .= " avg(st_x(centroid)) as lon, avg(st_y(centroid)) as lat,";
446             $sSQL .= $sImportanceSQL."-0.1 as importance, ";  // slightly smaller than the importance for normal houses with rank 30, which is 0
447             $sSQL .= " (select max(p.importance*(p.rank_address+2)) from place_addressline s, placex p";
448             $sSQL .= " where s.place_id = min(blub.parent_place_id) and p.place_id = s.address_place_id and s.isaddress and p.importance is not null) as addressimportance,";
449             $sSQL .= " null as extra_place ";
450             $sSQL .= " from (select place_id, calculated_country_code ";
451             // interpolate the housenumbers here
452             $sSQL .= ", CASE WHEN startnumber != endnumber THEN ST_LineInterpolatePoint(linegeo, (housenumber_for_place-startnumber::float)/(endnumber-startnumber)::float) ";
453             $sSQL .= " ELSE ST_LineInterpolatePoint(linegeo, 0.5) END as centroid";
454             $sSQL .= ", parent_place_id, housenumber_for_place ";
455             $sSQL .= " from (location_property_osmline ";
456             $sSQL .= " join (values ".$sHousenumbers.") as housenumbers(place_id, housenumber_for_place) using(place_id)) ";
457             $sSQL .= " where housenumber_for_place>=0 and 30 between $this->iMinAddressRank and $this->iMaxAddressRank) as blub"; //postgres wants an alias here
458             $sSQL .= " group by place_id, housenumber_for_place, calculated_country_code "; //is this group by really needed?, place_id + housenumber (in combination) are unique
459             if (!$this->bDeDupe) $sSQL .= ", place_id ";
460
461             if (CONST_Use_Aux_Location_data) {
462                 $sSQL .= " union ";
463                 $sSQL .= "select 'L' as osm_type, place_id as osm_id, 'place' as class, 'house' as type, null as admin_level, 0 as rank_search, 0 as rank_address, min(place_id) as place_id, min(parent_place_id) as parent_place_id, 'us' as country_code, ";
464                 $sSQL .= "get_address_by_language(place_id, -1, $sLanguagePrefArraySQL) as langaddress, ";
465                 $sSQL .= "null as placename, ";
466                 $sSQL .= "null as ref, ";
467                 if ($this->bIncludeExtraTags) $sSQL .= "null as extra, ";
468                 if ($this->bIncludeNameDetails) $sSQL .= "null as names, ";
469                 $sSQL .= "avg(ST_X(centroid)) as lon, avg(ST_Y(centroid)) as lat, ";
470                 $sSQL .= $sImportanceSQL."-1.10 as importance, ";
471                 $sSQL .= "(select max(p.importance*(p.rank_address+2)) from place_addressline s, placex p where s.place_id = min(location_property_aux.parent_place_id) and p.place_id = s.address_place_id and s.isaddress and p.importance is not null) as addressimportance, ";
472                 $sSQL .= "null as extra_place ";
473                 $sSQL .= "from location_property_aux where place_id in ($sPlaceIDs) ";
474                 $sSQL .= "and 30 between $this->iMinAddressRank and $this->iMaxAddressRank ";
475                 $sSQL .= "group by place_id";
476                 if (!$this->bDeDupe) $sSQL .= ", place_id";
477                 $sSQL .= ", get_address_by_language(place_id, -1, $sLanguagePrefArraySQL) ";
478             }
479         }
480
481         $sSQL .= " order by importance desc";
482         if (CONST_Debug) {
483             echo "<hr>";
484             var_dump($sSQL);
485         }
486         $aSearchResults = chksql(
487             $this->oDB->getAll($sSQL),
488             "Could not get details for place."
489         );
490
491         return $aSearchResults;
492     }
493
494     public function getGroupedSearches($aSearches, $aPhraseTypes, $aPhrases, $aValidTokens, $aWordFrequencyScores, $bStructuredPhrases)
495     {
496         /*
497              Calculate all searches using aValidTokens i.e.
498              'Wodsworth Road, Sheffield' =>
499
500              Phrase Wordset
501              0      0       (wodsworth road)
502              0      1       (wodsworth)(road)
503              1      0       (sheffield)
504
505              Score how good the search is so they can be ordered
506          */
507         foreach ($aPhrases as $iPhrase => $sPhrase) {
508             $aNewPhraseSearches = array();
509             if ($bStructuredPhrases) $sPhraseType = $aPhraseTypes[$iPhrase];
510             else $sPhraseType = '';
511
512             foreach ($aPhrases[$iPhrase]['wordsets'] as $iWordSet => $aWordset) {
513                 // Too many permutations - too expensive
514                 if ($iWordSet > 120) break;
515
516                 $aWordsetSearches = $aSearches;
517
518                 // Add all words from this wordset
519                 foreach ($aWordset as $iToken => $sToken) {
520                     //echo "<br><b>$sToken</b>";
521                     $aNewWordsetSearches = array();
522
523                     foreach ($aWordsetSearches as $aCurrentSearch) {
524                         //echo "<i>";
525                         //var_dump($aCurrentSearch);
526                         //echo "</i>";
527
528                         // If the token is valid
529                         if (isset($aValidTokens[' '.$sToken])) {
530                             foreach ($aValidTokens[' '.$sToken] as $aSearchTerm) {
531                                 $aSearch = $aCurrentSearch;
532                                 $aSearch['iSearchRank']++;
533                                 if (($sPhraseType == '' || $sPhraseType == 'country') && !empty($aSearchTerm['country_code']) && $aSearchTerm['country_code'] != '0') {
534                                     if ($aSearch['sCountryCode'] === false) {
535                                         $aSearch['sCountryCode'] = strtolower($aSearchTerm['country_code']);
536                                         // Country is almost always at the end of the string - increase score for finding it anywhere else (optimisation)
537                                         if (($iToken+1 != sizeof($aWordset) || $iPhrase+1 != sizeof($aPhrases))) {
538                                             $aSearch['iSearchRank'] += 5;
539                                         }
540                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
541                                     }
542                                 } elseif (isset($aSearchTerm['lat']) && $aSearchTerm['lat'] !== '' && $aSearchTerm['lat'] !== null) {
543                                     if ($aSearch['fLat'] === '') {
544                                         $aSearch['fLat'] = $aSearchTerm['lat'];
545                                         $aSearch['fLon'] = $aSearchTerm['lon'];
546                                         $aSearch['fRadius'] = $aSearchTerm['radius'];
547                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
548                                     }
549                                 } elseif ($sPhraseType == 'postalcode') {
550                                     // We need to try the case where the postal code is the primary element (i.e. no way to tell if it is (postalcode, city) OR (city, postalcode) so try both
551                                     if (isset($aSearchTerm['word_id']) && $aSearchTerm['word_id']) {
552                                         // If we already have a name try putting the postcode first
553                                         if (sizeof($aSearch['aName'])) {
554                                             $aNewSearch = $aSearch;
555                                             $aNewSearch['aAddress'] = array_merge($aNewSearch['aAddress'], $aNewSearch['aName']);
556                                             $aNewSearch['aName'] = array();
557                                             $aNewSearch['aName'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
558                                             if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aNewSearch;
559                                         }
560
561                                         if (sizeof($aSearch['aName'])) {
562                                             if ((!$bStructuredPhrases || $iPhrase > 0) && $sPhraseType != 'country' && (!isset($aValidTokens[$sToken]) || strpos($sToken, ' ') !== false)) {
563                                                 $aSearch['aAddress'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
564                                             } else {
565                                                 $aCurrentSearch['aFullNameAddress'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
566                                                 $aSearch['iSearchRank'] += 1000; // skip;
567                                             }
568                                         } else {
569                                             $aSearch['aName'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
570                                             //$aSearch['iNamePhrase'] = $iPhrase;
571                                         }
572                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
573                                     }
574                                 } elseif (($sPhraseType == '' || $sPhraseType == 'street') && $aSearchTerm['class'] == 'place' && $aSearchTerm['type'] == 'house') {
575                                     if ($aSearch['sHouseNumber'] === '') {
576                                         $aSearch['sHouseNumber'] = $sToken;
577                                         // sanity check: if the housenumber is not mainly made
578                                         // up of numbers, add a penalty
579                                         if (preg_match_all("/[^0-9]/", $sToken, $aMatches) > 2) $aSearch['iSearchRank']++;
580                                         // also housenumbers should appear in the first or second phrase
581                                         if ($iPhrase > 1) $aSearch['iSearchRank'] += 1;
582                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
583                                         /*
584                                         // Fall back to not searching for this item (better than nothing)
585                                         $aSearch = $aCurrentSearch;
586                                         $aSearch['iSearchRank'] += 1;
587                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
588                                          */
589                                     }
590                                 } elseif ($sPhraseType == '' && $aSearchTerm['class'] !== '' && $aSearchTerm['class'] !== null) {
591                                     if ($aSearch['sClass'] === '') {
592                                         $aSearch['sOperator'] = $aSearchTerm['operator'];
593                                         $aSearch['sClass'] = $aSearchTerm['class'];
594                                         $aSearch['sType'] = $aSearchTerm['type'];
595                                         if (sizeof($aSearch['aName'])) $aSearch['sOperator'] = 'name';
596                                         else $aSearch['sOperator'] = 'near'; // near = in for the moment
597                                         if (strlen($aSearchTerm['operator']) == 0) $aSearch['iSearchRank'] += 1;
598
599                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
600                                     }
601                                 } elseif (isset($aSearchTerm['word_id']) && $aSearchTerm['word_id']) {
602                                     if (sizeof($aSearch['aName'])) {
603                                         if ((!$bStructuredPhrases || $iPhrase > 0) && $sPhraseType != 'country' && (!isset($aValidTokens[$sToken]) || strpos($sToken, ' ') !== false)) {
604                                             $aSearch['aAddress'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
605                                         } else {
606                                             $aCurrentSearch['aFullNameAddress'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
607                                             $aSearch['iSearchRank'] += 1000; // skip;
608                                         }
609                                     } else {
610                                         $aSearch['aName'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
611                                         //$aSearch['iNamePhrase'] = $iPhrase;
612                                     }
613                                     if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
614                                 }
615                             }
616                         }
617                         // Look for partial matches.
618                         // Note that there is no point in adding country terms here
619                         // because country are omitted in the address.
620                         if (isset($aValidTokens[$sToken]) && $sPhraseType != 'country') {
621                             // Allow searching for a word - but at extra cost
622                             foreach ($aValidTokens[$sToken] as $aSearchTerm) {
623                                 if (isset($aSearchTerm['word_id']) && $aSearchTerm['word_id']) {
624                                     if ((!$bStructuredPhrases || $iPhrase > 0) && sizeof($aCurrentSearch['aName']) && strpos($sToken, ' ') === false) {
625                                         $aSearch = $aCurrentSearch;
626                                         $aSearch['iSearchRank'] += 1;
627                                         if ($aWordFrequencyScores[$aSearchTerm['word_id']] < CONST_Max_Word_Frequency) {
628                                             $aSearch['aAddress'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
629                                             if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
630                                         } elseif (isset($aValidTokens[' '.$sToken])) { // revert to the token version?
631                                             $aSearch['aAddressNonSearch'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
632                                             $aSearch['iSearchRank'] += 1;
633                                             if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
634                                             foreach ($aValidTokens[' '.$sToken] as $aSearchTermToken) {
635                                                 if (empty($aSearchTermToken['country_code'])
636                                                     && empty($aSearchTermToken['lat'])
637                                                     && empty($aSearchTermToken['class'])
638                                                 ) {
639                                                     $aSearch = $aCurrentSearch;
640                                                     $aSearch['iSearchRank'] += 1;
641                                                     $aSearch['aAddress'][$aSearchTermToken['word_id']] = $aSearchTermToken['word_id'];
642                                                     if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
643                                                 }
644                                             }
645                                         } else {
646                                             $aSearch['aAddressNonSearch'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
647                                             if (preg_match('#^[0-9]+$#', $sToken)) $aSearch['iSearchRank'] += 2;
648                                             if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
649                                         }
650                                     }
651
652                                     if (!sizeof($aCurrentSearch['aName']) || $aCurrentSearch['iNamePhrase'] == $iPhrase) {
653                                         $aSearch = $aCurrentSearch;
654                                         $aSearch['iSearchRank'] += 1;
655                                         if (!sizeof($aCurrentSearch['aName'])) $aSearch['iSearchRank'] += 1;
656                                         if (preg_match('#^[0-9]+$#', $sToken)) $aSearch['iSearchRank'] += 2;
657                                         if ($aWordFrequencyScores[$aSearchTerm['word_id']] < CONST_Max_Word_Frequency) {
658                                             $aSearch['aName'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
659                                         } else {
660                                             $aSearch['aNameNonSearch'][$aSearchTerm['word_id']] = $aSearchTerm['word_id'];
661                                         }
662                                         $aSearch['iNamePhrase'] = $iPhrase;
663                                         if ($aSearch['iSearchRank'] < $this->iMaxRank) $aNewWordsetSearches[] = $aSearch;
664                                     }
665                                 }
666                             }
667                         } else {
668                             // Allow skipping a word - but at EXTREAM cost
669                             //$aSearch = $aCurrentSearch;
670                             //$aSearch['iSearchRank']+=100;
671                             //$aNewWordsetSearches[] = $aSearch;
672                         }
673                     }
674                     // Sort and cut
675                     usort($aNewWordsetSearches, 'bySearchRank');
676                     $aWordsetSearches = array_slice($aNewWordsetSearches, 0, 50);
677                 }
678                 //var_Dump('<hr>',sizeof($aWordsetSearches)); exit;
679
680                 $aNewPhraseSearches = array_merge($aNewPhraseSearches, $aNewWordsetSearches);
681                 usort($aNewPhraseSearches, 'bySearchRank');
682
683                 $aSearchHash = array();
684                 foreach ($aNewPhraseSearches as $iSearch => $aSearch) {
685                     $sHash = serialize($aSearch);
686                     if (isset($aSearchHash[$sHash])) unset($aNewPhraseSearches[$iSearch]);
687                     else $aSearchHash[$sHash] = 1;
688                 }
689
690                 $aNewPhraseSearches = array_slice($aNewPhraseSearches, 0, 50);
691             }
692
693             // Re-group the searches by their score, junk anything over 20 as just not worth trying
694             $aGroupedSearches = array();
695             foreach ($aNewPhraseSearches as $aSearch) {
696                 if ($aSearch['iSearchRank'] < $this->iMaxRank) {
697                     if (!isset($aGroupedSearches[$aSearch['iSearchRank']])) $aGroupedSearches[$aSearch['iSearchRank']] = array();
698                     $aGroupedSearches[$aSearch['iSearchRank']][] = $aSearch;
699                 }
700             }
701             ksort($aGroupedSearches);
702
703             $iSearchCount = 0;
704             $aSearches = array();
705             foreach ($aGroupedSearches as $iScore => $aNewSearches) {
706                 $iSearchCount += sizeof($aNewSearches);
707                 $aSearches = array_merge($aSearches, $aNewSearches);
708                 if ($iSearchCount > 50) break;
709             }
710
711             //if (CONST_Debug) _debugDumpGroupedSearches($aGroupedSearches, $aValidTokens);
712         }
713         return $aGroupedSearches;
714     }
715
716     /* Perform the actual query lookup.
717
718         Returns an ordered list of results, each with the following fields:
719             osm_type: type of corresponding OSM object
720                         N - node
721                         W - way
722                         R - relation
723                         P - postcode (internally computed)
724             osm_id: id of corresponding OSM object
725             class: general object class (corresponds to tag key of primary OSM tag)
726             type: subclass of object (corresponds to tag value of primary OSM tag)
727             admin_level: see http://wiki.openstreetmap.org/wiki/Admin_level
728             rank_search: rank in search hierarchy
729                         (see also http://wiki.openstreetmap.org/wiki/Nominatim/Development_overview#Country_to_street_level)
730             rank_address: rank in address hierarchy (determines orer in address)
731             place_id: internal key (may differ between different instances)
732             country_code: ISO country code
733             langaddress: localized full address
734             placename: localized name of object
735             ref: content of ref tag (if available)
736             lon: longitude
737             lat: latitude
738             importance: importance of place based on Wikipedia link count
739             addressimportance: cumulated importance of address elements
740             extra_place: type of place (for admin boundaries, if there is a place tag)
741             aBoundingBox: bounding Box
742             label: short description of the object class/type (English only)
743             name: full name (currently the same as langaddress)
744             foundorder: secondary ordering for places with same importance
745     */
746
747
748     public function lookup()
749     {
750         if (!$this->sQuery && !$this->aStructuredQuery) return false;
751
752         $sLanguagePrefArraySQL = "ARRAY[".join(',', array_map("getDBQuoted", $this->aLangPrefOrder))."]";
753         $sCountryCodesSQL = false;
754         if ($this->aCountryCodes) {
755             $sCountryCodesSQL = join(',', array_map('addQuotes', $this->aCountryCodes));
756         }
757
758         $sQuery = $this->sQuery;
759
760         // Conflicts between US state abreviations and various words for 'the' in different languages
761         if (isset($this->aLangPrefOrder['name:en'])) {
762             $sQuery = preg_replace('/(^|,)\s*il\s*(,|$)/', '\1illinois\2', $sQuery);
763             $sQuery = preg_replace('/(^|,)\s*al\s*(,|$)/', '\1alabama\2', $sQuery);
764             $sQuery = preg_replace('/(^|,)\s*la\s*(,|$)/', '\1louisiana\2', $sQuery);
765         }
766
767         $bBoundingBoxSearch = $this->bBoundedSearch && $this->sViewboxSmallSQL;
768         if ($this->sViewboxCentreSQL) {
769             // For complex viewboxes (routes) precompute the bounding geometry
770             $sGeom = chksql(
771                 $this->oDB->getOne("select ".$this->sViewboxSmallSQL),
772                 "Could not get small viewbox"
773             );
774             $this->sViewboxSmallSQL = "'".$sGeom."'::geometry";
775
776             $sGeom = chksql(
777                 $this->oDB->getOne("select ".$this->sViewboxLargeSQL),
778                 "Could not get large viewbox"
779             );
780             $this->sViewboxLargeSQL = "'".$sGeom."'::geometry";
781         }
782
783         // Do we have anything that looks like a lat/lon pair?
784         if ($aLooksLike = looksLikeLatLonPair($sQuery)) {
785             $this->setNearPoint(array($aLooksLike['lat'], $aLooksLike['lon']));
786             $sQuery = $aLooksLike['query'];
787         }
788
789         $aSearchResults = array();
790         if ($sQuery || $this->aStructuredQuery) {
791             // Start with a blank search
792             $aSearches = array(
793                           array(
794                            'iSearchRank' => 0,
795                            'iNamePhrase' => -1,
796                            'sCountryCode' => false,
797                            'aName' => array(),
798                            'aAddress' => array(),
799                            'aFullNameAddress' => array(),
800                            'aNameNonSearch' => array(),
801                            'aAddressNonSearch' => array(),
802                            'sOperator' => '',
803                            'aFeatureName' => array(),
804                            'sClass' => '',
805                            'sType' => '',
806                            'sHouseNumber' => '',
807                            'fLat' => '',
808                            'fLon' => '',
809                            'fRadius' => ''
810                           )
811                          );
812
813             // Do we have a radius search?
814             $sNearPointSQL = false;
815             if ($this->aNearPoint) {
816                 $sNearPointSQL = "ST_SetSRID(ST_Point(".(float)$this->aNearPoint[1].",".(float)$this->aNearPoint[0]."),4326)";
817                 $aSearches[0]['fLat'] = (float)$this->aNearPoint[0];
818                 $aSearches[0]['fLon'] = (float)$this->aNearPoint[1];
819                 $aSearches[0]['fRadius'] = (float)$this->aNearPoint[2];
820             }
821
822             // Any 'special' terms in the search?
823             $bSpecialTerms = false;
824             preg_match_all('/\\[(.*)=(.*)\\]/', $sQuery, $aSpecialTermsRaw, PREG_SET_ORDER);
825             $aSpecialTerms = array();
826             foreach ($aSpecialTermsRaw as $aSpecialTerm) {
827                 $sQuery = str_replace($aSpecialTerm[0], ' ', $sQuery);
828                 $aSpecialTerms[strtolower($aSpecialTerm[1])] = $aSpecialTerm[2];
829             }
830
831             preg_match_all('/\\[([\\w ]*)\\]/u', $sQuery, $aSpecialTermsRaw, PREG_SET_ORDER);
832             $aSpecialTerms = array();
833             if (isset($this->aStructuredQuery['amenity']) && $this->aStructuredQuery['amenity']) {
834                 $aSpecialTermsRaw[] = array('['.$this->aStructuredQuery['amenity'].']', $this->aStructuredQuery['amenity']);
835                 unset($this->aStructuredQuery['amenity']);
836             }
837
838             foreach ($aSpecialTermsRaw as $aSpecialTerm) {
839                 $sQuery = str_replace($aSpecialTerm[0], ' ', $sQuery);
840                 $sToken = chksql($this->oDB->getOne("select make_standard_name('".$aSpecialTerm[1]."') as string"));
841                 $sSQL = 'select * from (select word_id,word_token, word, class, type, country_code, operator';
842                 $sSQL .= ' from word where word_token in (\' '.$sToken.'\')) as x where (class is not null and class not in (\'place\')) or country_code is not null';
843                 if (CONST_Debug) var_Dump($sSQL);
844                 $aSearchWords = chksql($this->oDB->getAll($sSQL));
845                 $aNewSearches = array();
846                 foreach ($aSearches as $aSearch) {
847                     foreach ($aSearchWords as $aSearchTerm) {
848                         $aNewSearch = $aSearch;
849                         if ($aSearchTerm['country_code']) {
850                             $aNewSearch['sCountryCode'] = strtolower($aSearchTerm['country_code']);
851                             $aNewSearches[] = $aNewSearch;
852                             $bSpecialTerms = true;
853                         }
854                         if ($aSearchTerm['class']) {
855                             $aNewSearch['sClass'] = $aSearchTerm['class'];
856                             $aNewSearch['sType'] = $aSearchTerm['type'];
857                             $aNewSearches[] = $aNewSearch;
858                             $bSpecialTerms = true;
859                         }
860                     }
861                 }
862                 $aSearches = $aNewSearches;
863             }
864
865             // Split query into phrases
866             // Commas are used to reduce the search space by indicating where phrases split
867             if ($this->aStructuredQuery) {
868                 $aPhrases = $this->aStructuredQuery;
869                 $bStructuredPhrases = true;
870             } else {
871                 $aPhrases = explode(',', $sQuery);
872                 $bStructuredPhrases = false;
873             }
874
875             // Convert each phrase to standard form
876             // Create a list of standard words
877             // Get all 'sets' of words
878             // Generate a complete list of all
879             $aTokens = array();
880             foreach ($aPhrases as $iPhrase => $sPhrase) {
881                 $aPhrase = chksql(
882                     $this->oDB->getRow("select make_standard_name('".pg_escape_string($sPhrase)."') as string"),
883                     "Cannot nomralize query string (is it an UTF-8 string?)"
884                 );
885                 if (trim($aPhrase['string'])) {
886                     $aPhrases[$iPhrase] = $aPhrase;
887                     $aPhrases[$iPhrase]['words'] = explode(' ', $aPhrases[$iPhrase]['string']);
888                     $aPhrases[$iPhrase]['wordsets'] = getWordSets($aPhrases[$iPhrase]['words'], 0);
889                     $aTokens = array_merge($aTokens, getTokensFromSets($aPhrases[$iPhrase]['wordsets']));
890                 } else {
891                     unset($aPhrases[$iPhrase]);
892                 }
893             }
894
895             // Reindex phrases - we make assumptions later on that they are numerically keyed in order
896             $aPhraseTypes = array_keys($aPhrases);
897             $aPhrases = array_values($aPhrases);
898
899             if (sizeof($aTokens)) {
900                 // Check which tokens we have, get the ID numbers
901                 $sSQL = 'select word_id,word_token, word, class, type, country_code, operator, search_name_count';
902                 $sSQL .= ' from word where word_token in ('.join(',', array_map("getDBQuoted", $aTokens)).')';
903
904                 if (CONST_Debug) var_Dump($sSQL);
905
906                 $aValidTokens = array();
907                 if (sizeof($aTokens)) {
908                     $aDatabaseWords = chksql(
909                         $this->oDB->getAll($sSQL),
910                         "Could not get word tokens."
911                     );
912                 } else {
913                     $aDatabaseWords = array();
914                 }
915                 $aPossibleMainWordIDs = array();
916                 $aWordFrequencyScores = array();
917                 foreach ($aDatabaseWords as $aToken) {
918                     // Very special case - require 2 letter country param to match the country code found
919                     if ($bStructuredPhrases && $aToken['country_code'] && !empty($this->aStructuredQuery['country'])
920                         && strlen($this->aStructuredQuery['country']) == 2 && strtolower($this->aStructuredQuery['country']) != $aToken['country_code']
921                     ) {
922                         continue;
923                     }
924
925                     if (isset($aValidTokens[$aToken['word_token']])) {
926                         $aValidTokens[$aToken['word_token']][] = $aToken;
927                     } else {
928                         $aValidTokens[$aToken['word_token']] = array($aToken);
929                     }
930                     if (!$aToken['class'] && !$aToken['country_code']) $aPossibleMainWordIDs[$aToken['word_id']] = 1;
931                     $aWordFrequencyScores[$aToken['word_id']] = $aToken['search_name_count'] + 1;
932                 }
933                 if (CONST_Debug) var_Dump($aPhrases, $aValidTokens);
934
935                 // Try and calculate GB postcodes we might be missing
936                 foreach ($aTokens as $sToken) {
937                     // Source of gb postcodes is now definitive - always use
938                     if (preg_match('/^([A-Z][A-Z]?[0-9][0-9A-Z]? ?[0-9])([A-Z][A-Z])$/', strtoupper(trim($sToken)), $aData)) {
939                         if (substr($aData[1], -2, 1) != ' ') {
940                             $aData[0] = substr($aData[0], 0, strlen($aData[1])-1).' '.substr($aData[0], strlen($aData[1])-1);
941                             $aData[1] = substr($aData[1], 0, -1).' '.substr($aData[1], -1, 1);
942                         }
943                         $aGBPostcodeLocation = gbPostcodeCalculate($aData[0], $aData[1], $aData[2], $this->oDB);
944                         if ($aGBPostcodeLocation) {
945                             $aValidTokens[$sToken] = $aGBPostcodeLocation;
946                         }
947                     } elseif (!isset($aValidTokens[$sToken]) && preg_match('/^([0-9]{5}) [0-9]{4}$/', $sToken, $aData)) {
948                         // US ZIP+4 codes - if there is no token,
949                         // merge in the 5-digit ZIP code
950                         if (isset($aValidTokens[$aData[1]])) {
951                             foreach ($aValidTokens[$aData[1]] as $aToken) {
952                                 if (!$aToken['class']) {
953                                     if (isset($aValidTokens[$sToken])) {
954                                         $aValidTokens[$sToken][] = $aToken;
955                                     } else {
956                                         $aValidTokens[$sToken] = array($aToken);
957                                     }
958                                 }
959                             }
960                         }
961                     }
962                 }
963
964                 foreach ($aTokens as $sToken) {
965                     // Unknown single word token with a number - assume it is a house number
966                     if (!isset($aValidTokens[' '.$sToken]) && strpos($sToken, ' ') === false && preg_match('/[0-9]/', $sToken)) {
967                         $aValidTokens[' '.$sToken] = array(array('class' => 'place', 'type' => 'house'));
968                     }
969                 }
970
971                 // Any words that have failed completely?
972                 // TODO: suggestions
973
974                 // Start the search process
975                 // array with: placeid => -1 | tiger-housenumber
976                 $aResultPlaceIDs = array();
977
978                 $aGroupedSearches = $this->getGroupedSearches($aSearches, $aPhraseTypes, $aPhrases, $aValidTokens, $aWordFrequencyScores, $bStructuredPhrases);
979
980                 if ($this->bReverseInPlan) {
981                     // Reverse phrase array and also reverse the order of the wordsets in
982                     // the first and final phrase. Don't bother about phrases in the middle
983                     // because order in the address doesn't matter.
984                     $aPhrases = array_reverse($aPhrases);
985                     $aPhrases[0]['wordsets'] = getInverseWordSets($aPhrases[0]['words'], 0);
986                     if (sizeof($aPhrases) > 1) {
987                         $aFinalPhrase = end($aPhrases);
988                         $aPhrases[sizeof($aPhrases)-1]['wordsets'] = getInverseWordSets($aFinalPhrase['words'], 0);
989                     }
990                     $aReverseGroupedSearches = $this->getGroupedSearches($aSearches, null, $aPhrases, $aValidTokens, $aWordFrequencyScores, false);
991
992                     foreach ($aGroupedSearches as $aSearches) {
993                         foreach ($aSearches as $aSearch) {
994                             if ($aSearch['iSearchRank'] < $this->iMaxRank) {
995                                 if (!isset($aReverseGroupedSearches[$aSearch['iSearchRank']])) $aReverseGroupedSearches[$aSearch['iSearchRank']] = array();
996                                 $aReverseGroupedSearches[$aSearch['iSearchRank']][] = $aSearch;
997                             }
998                         }
999                     }
1000
1001                     $aGroupedSearches = $aReverseGroupedSearches;
1002                     ksort($aGroupedSearches);
1003                 }
1004             } else {
1005                 // Re-group the searches by their score, junk anything over 20 as just not worth trying
1006                 $aGroupedSearches = array();
1007                 foreach ($aSearches as $aSearch) {
1008                     if ($aSearch['iSearchRank'] < $this->iMaxRank) {
1009                         if (!isset($aGroupedSearches[$aSearch['iSearchRank']])) $aGroupedSearches[$aSearch['iSearchRank']] = array();
1010                         $aGroupedSearches[$aSearch['iSearchRank']][] = $aSearch;
1011                     }
1012                 }
1013                 ksort($aGroupedSearches);
1014             }
1015
1016             if (CONST_Debug) var_Dump($aGroupedSearches);
1017             if (CONST_Search_TryDroppedAddressTerms && sizeof($this->aStructuredQuery) > 0) {
1018                 $aCopyGroupedSearches = $aGroupedSearches;
1019                 foreach ($aCopyGroupedSearches as $iGroup => $aSearches) {
1020                     foreach ($aSearches as $iSearch => $aSearch) {
1021                         $aReductionsList = array($aSearch['aAddress']);
1022                         $iSearchRank = $aSearch['iSearchRank'];
1023                         while (sizeof($aReductionsList) > 0) {
1024                             $iSearchRank += 5;
1025                             if ($iSearchRank > iMaxRank) break 3;
1026                             $aNewReductionsList = array();
1027                             foreach ($aReductionsList as $aReductionsWordList) {
1028                                 for ($iReductionWord = 0; $iReductionWord < sizeof($aReductionsWordList); $iReductionWord++) {
1029                                     $aReductionsWordListResult = array_merge(array_slice($aReductionsWordList, 0, $iReductionWord), array_slice($aReductionsWordList, $iReductionWord+1));
1030                                     $aReverseSearch = $aSearch;
1031                                     $aSearch['aAddress'] = $aReductionsWordListResult;
1032                                     $aSearch['iSearchRank'] = $iSearchRank;
1033                                     $aGroupedSearches[$iSearchRank][] = $aReverseSearch;
1034                                     if (sizeof($aReductionsWordListResult) > 0) {
1035                                         $aNewReductionsList[] = $aReductionsWordListResult;
1036                                     }
1037                                 }
1038                             }
1039                             $aReductionsList = $aNewReductionsList;
1040                         }
1041                     }
1042                 }
1043                 ksort($aGroupedSearches);
1044             }
1045
1046             // Filter out duplicate searches
1047             $aSearchHash = array();
1048             foreach ($aGroupedSearches as $iGroup => $aSearches) {
1049                 foreach ($aSearches as $iSearch => $aSearch) {
1050                     $sHash = serialize($aSearch);
1051                     if (isset($aSearchHash[$sHash])) {
1052                         unset($aGroupedSearches[$iGroup][$iSearch]);
1053                         if (sizeof($aGroupedSearches[$iGroup]) == 0) unset($aGroupedSearches[$iGroup]);
1054                     } else {
1055                         $aSearchHash[$sHash] = 1;
1056                     }
1057                 }
1058             }
1059
1060             if (CONST_Debug) _debugDumpGroupedSearches($aGroupedSearches, $aValidTokens);
1061
1062             $iGroupLoop = 0;
1063             $iQueryLoop = 0;
1064             foreach ($aGroupedSearches as $iGroupedRank => $aSearches) {
1065                 $iGroupLoop++;
1066                 foreach ($aSearches as $aSearch) {
1067                     $iQueryLoop++;
1068                     $searchedHousenumber = -1;
1069
1070                     if (CONST_Debug) echo "<hr><b>Search Loop, group $iGroupLoop, loop $iQueryLoop</b>";
1071                     if (CONST_Debug) _debugDumpGroupedSearches(array($iGroupedRank => array($aSearch)), $aValidTokens);
1072
1073                     // No location term?
1074                     if (!sizeof($aSearch['aName']) && !sizeof($aSearch['aAddress']) && !$aSearch['fLon']) {
1075                         if ($aSearch['sCountryCode'] && !$aSearch['sClass'] && !$aSearch['sHouseNumber']) {
1076                             // Just looking for a country by code - look it up
1077                             if (4 >= $this->iMinAddressRank && 4 <= $this->iMaxAddressRank) {
1078                                 $sSQL = "select place_id from placex where calculated_country_code='".$aSearch['sCountryCode']."' and rank_search = 4";
1079                                 if ($sCountryCodesSQL) $sSQL .= " and calculated_country_code in ($sCountryCodesSQL)";
1080                                 if ($bBoundingBoxSearch)
1081                                     $sSQL .= " and _st_intersects($this->sViewboxSmallSQL, geometry)";
1082                                 $sSQL .= " order by st_area(geometry) desc limit 1";
1083                                 if (CONST_Debug) var_dump($sSQL);
1084                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1085                             } else {
1086                                 $aPlaceIDs = array();
1087                             }
1088                         } else {
1089                             if (!$bBoundingBoxSearch && !$aSearch['fLon']) continue;
1090                             if (!$aSearch['sClass']) continue;
1091
1092                             $sSQL = "select count(*) from pg_tables where tablename = 'place_classtype_".$aSearch['sClass']."_".$aSearch['sType']."'";
1093                             if (chksql($this->oDB->getOne($sSQL))) {
1094                                 $sSQL = "select place_id from place_classtype_".$aSearch['sClass']."_".$aSearch['sType']." ct";
1095                                 if ($sCountryCodesSQL) $sSQL .= " join placex using (place_id)";
1096                                 $sSQL .= " where st_contains($this->sViewboxSmallSQL, ct.centroid)";
1097                                 if ($sCountryCodesSQL) $sSQL .= " and calculated_country_code in ($sCountryCodesSQL)";
1098                                 if (sizeof($this->aExcludePlaceIDs)) {
1099                                     $sSQL .= " and place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1100                                 }
1101                                 if ($this->sViewboxCentreSQL) $sSQL .= " order by st_distance($this->sViewboxCentreSQL, ct.centroid) asc";
1102                                 $sSQL .= " limit $this->iLimit";
1103                                 if (CONST_Debug) var_dump($sSQL);
1104                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1105
1106                                 // If excluded place IDs are given, it is fair to assume that
1107                                 // there have been results in the small box, so no further
1108                                 // expansion in that case.
1109                                 // Also don't expand if bounded results were requested.
1110                                 if (!sizeof($aPlaceIDs) && !sizeof($this->aExcludePlaceIDs) && !$this->bBoundedSearch) {
1111                                     $sSQL = "select place_id from place_classtype_".$aSearch['sClass']."_".$aSearch['sType']." ct";
1112                                     if ($sCountryCodesSQL) $sSQL .= " join placex using (place_id)";
1113                                     $sSQL .= " where st_contains($this->sViewboxLargeSQL, ct.centroid)";
1114                                     if ($sCountryCodesSQL) $sSQL .= " and calculated_country_code in ($sCountryCodesSQL)";
1115                                     if ($this->sViewboxCentreSQL) $sSQL .= " order by st_distance($this->sViewboxCentreSQL, ct.centroid) asc";
1116                                     $sSQL .= " limit $this->iLimit";
1117                                     if (CONST_Debug) var_dump($sSQL);
1118                                     $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1119                                 }
1120                             } else {
1121                                 $sSQL = "select place_id from placex where class='".$aSearch['sClass']."' and type='".$aSearch['sType']."'";
1122                                 $sSQL .= " and st_contains($this->sViewboxSmallSQL, geometry) and linked_place_id is null";
1123                                 if ($sCountryCodesSQL) $sSQL .= " and calculated_country_code in ($sCountryCodesSQL)";
1124                                 if ($this->sViewboxCentreSQL)   $sSQL .= " order by st_distance($this->sViewboxCentreSQL, centroid) asc";
1125                                 $sSQL .= " limit $this->iLimit";
1126                                 if (CONST_Debug) var_dump($sSQL);
1127                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1128                             }
1129                         }
1130                     } elseif ($aSearch['fLon'] && !sizeof($aSearch['aName']) && !sizeof($aSearch['aAddress']) && !$aSearch['sClass']) {
1131                         // If a coordinate is given, the search must either
1132                         // be for a name or a special search. Ignore everythin else.
1133                         $aPlaceIDs = array();
1134                     } else {
1135                         $aPlaceIDs = array();
1136
1137                         // First we need a position, either aName or fLat or both
1138                         $aTerms = array();
1139                         $aOrder = array();
1140
1141                         if ($aSearch['sHouseNumber'] && sizeof($aSearch['aAddress'])) {
1142                             $sHouseNumberRegex = '\\\\m'.$aSearch['sHouseNumber'].'\\\\M';
1143                             $aOrder[] = "";
1144                             $aOrder[0] = " (exists(select place_id from placex where parent_place_id = search_name.place_id";
1145                             $aOrder[0] .= " and transliteration(housenumber) ~* E'".$sHouseNumberRegex."' limit 1) ";
1146                             // also housenumbers from interpolation lines table are needed
1147                             $aOrder[0] .= " or exists(select place_id from location_property_osmline where parent_place_id = search_name.place_id";
1148                             $aOrder[0] .= " and ".intval($aSearch['sHouseNumber']).">=startnumber and ".intval($aSearch['sHouseNumber'])."<=endnumber limit 1))";
1149                             $aOrder[0] .= " desc";
1150                         }
1151
1152                         // TODO: filter out the pointless search terms (2 letter name tokens and less)
1153                         // they might be right - but they are just too darned expensive to run
1154                         if (sizeof($aSearch['aName'])) $aTerms[] = "name_vector @> ARRAY[".join($aSearch['aName'], ",")."]";
1155                         //if (sizeof($aSearch['aNameNonSearch'])) $aTerms[] = "array_cat(name_vector,ARRAY[]::integer[]) @> ARRAY[".join($aSearch['aNameNonSearch'], ",")."]";
1156                         if (sizeof($aSearch['aAddress']) && $aSearch['aName'] != $aSearch['aAddress']) {
1157                             // For infrequent name terms disable index usage for address
1158                             if (CONST_Search_NameOnlySearchFrequencyThreshold
1159                                 && sizeof($aSearch['aName']) == 1
1160                                 && $aWordFrequencyScores[$aSearch['aName'][reset($aSearch['aName'])]] < CONST_Search_NameOnlySearchFrequencyThreshold
1161                             ) {
1162                                 //$aTerms[] = "array_cat(nameaddress_vector,ARRAY[]::integer[]) @> ARRAY[".join(array_merge($aSearch['aAddress'], $aSearch['aAddressNonSearch']), ",")."]";
1163                                 $aTerms[] = "array_cat(nameaddress_vector,ARRAY[]::integer[]) @> ARRAY[".join($aSearch['aAddress'],",")."]";
1164                             } else {
1165                                 $aTerms[] = "nameaddress_vector @> ARRAY[".join($aSearch['aAddress'], ",")."]";
1166                                 /*if (sizeof($aSearch['aAddressNonSearch'])) {
1167                                     $aTerms[] = "array_cat(nameaddress_vector,ARRAY[]::integer[]) @> ARRAY[".join($aSearch['aAddressNonSearch'], ",")."]";
1168                                 }*/
1169                             }
1170                         }
1171                         if ($aSearch['sCountryCode']) $aTerms[] = "country_code = '".pg_escape_string($aSearch['sCountryCode'])."'";
1172                         if ($aSearch['sHouseNumber']) {
1173                             $aTerms[] = "address_rank between 16 and 27";
1174                         } else {
1175                             if ($this->iMinAddressRank > 0) {
1176                                 $aTerms[] = "address_rank >= ".$this->iMinAddressRank;
1177                             }
1178                             if ($this->iMaxAddressRank < 30) {
1179                                 $aTerms[] = "address_rank <= ".$this->iMaxAddressRank;
1180                             }
1181                         }
1182                         if ($aSearch['fLon'] && $aSearch['fLat']) {
1183                             $aTerms[] = "ST_DWithin(centroid, ST_SetSRID(ST_Point(".$aSearch['fLon'].",".$aSearch['fLat']."),4326), ".$aSearch['fRadius'].")";
1184                             $aOrder[] = "ST_Distance(centroid, ST_SetSRID(ST_Point(".$aSearch['fLon'].",".$aSearch['fLat']."),4326)) ASC";
1185                         }
1186                         if (sizeof($this->aExcludePlaceIDs)) {
1187                             $aTerms[] = "place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1188                         }
1189                         if ($sCountryCodesSQL) {
1190                             $aTerms[] = "country_code in ($sCountryCodesSQL)";
1191                         }
1192
1193                         if ($bBoundingBoxSearch) $aTerms[] = "centroid && $this->sViewboxSmallSQL";
1194                         if ($sNearPointSQL) $aOrder[] = "ST_Distance($sNearPointSQL, centroid) asc";
1195
1196                         if ($aSearch['sHouseNumber']) {
1197                             $sImportanceSQL = '- abs(26 - address_rank) + 3';
1198                         } else {
1199                             $sImportanceSQL = '(case when importance = 0 OR importance IS NULL then 0.75-(search_rank::float/40) else importance end)';
1200                         }
1201                         if ($this->sViewboxSmallSQL) $sImportanceSQL .= " * case when ST_Contains($this->sViewboxSmallSQL, centroid) THEN 1 ELSE 0.5 END";
1202                         if ($this->sViewboxLargeSQL) $sImportanceSQL .= " * case when ST_Contains($this->sViewboxLargeSQL, centroid) THEN 1 ELSE 0.5 END";
1203
1204                         $aOrder[] = "$sImportanceSQL DESC";
1205                         if (sizeof($aSearch['aFullNameAddress'])) {
1206                             $sExactMatchSQL = '(select count(*) from (select unnest(ARRAY['.join($aSearch['aFullNameAddress'], ",").']) INTERSECT select unnest(nameaddress_vector))s) as exactmatch';
1207                             $aOrder[] = 'exactmatch DESC';
1208                         } else {
1209                             $sExactMatchSQL = '0::int as exactmatch';
1210                         }
1211
1212                         if (sizeof($aTerms)) {
1213                             $sSQL = "select place_id, ";
1214                             $sSQL .= $sExactMatchSQL;
1215                             $sSQL .= " from search_name";
1216                             $sSQL .= " where ".join(' and ', $aTerms);
1217                             $sSQL .= " order by ".join(', ', $aOrder);
1218                             if ($aSearch['sHouseNumber'] || $aSearch['sClass']) {
1219                                 $sSQL .= " limit 20";
1220                             } elseif (!sizeof($aSearch['aName']) && !sizeof($aSearch['aAddress']) && $aSearch['sClass']) {
1221                                 $sSQL .= " limit 1";
1222                             } else {
1223                                 $sSQL .= " limit ".$this->iLimit;
1224                             }
1225
1226                             if (CONST_Debug) var_dump($sSQL);
1227                             $aViewBoxPlaceIDs = chksql(
1228                                 $this->oDB->getAll($sSQL),
1229                                 "Could not get places for search terms."
1230                             );
1231                             //var_dump($aViewBoxPlaceIDs);
1232                             // Did we have an viewbox matches?
1233                             $aPlaceIDs = array();
1234                             $bViewBoxMatch = false;
1235                             foreach ($aViewBoxPlaceIDs as $aViewBoxRow) {
1236                                 //if ($bViewBoxMatch == 1 && $aViewBoxRow['in_small'] == 'f') break;
1237                                 //if ($bViewBoxMatch == 2 && $aViewBoxRow['in_large'] == 'f') break;
1238                                 //if ($aViewBoxRow['in_small'] == 't') $bViewBoxMatch = 1;
1239                                 //else if ($aViewBoxRow['in_large'] == 't') $bViewBoxMatch = 2;
1240                                 $aPlaceIDs[] = $aViewBoxRow['place_id'];
1241                                 $this->exactMatchCache[$aViewBoxRow['place_id']] = $aViewBoxRow['exactmatch'];
1242                             }
1243                         }
1244                         //var_Dump($aPlaceIDs);
1245                         //exit;
1246
1247                         //now search for housenumber, if housenumber provided
1248                         if ($aSearch['sHouseNumber'] && sizeof($aPlaceIDs)) {
1249                             $searchedHousenumber = intval($aSearch['sHouseNumber']);
1250                             $aRoadPlaceIDs = $aPlaceIDs;
1251                             $sPlaceIDs = join(',', $aPlaceIDs);
1252
1253                             // Now they are indexed, look for a house attached to a street we found
1254                             $sHouseNumberRegex = '\\\\m'.$aSearch['sHouseNumber'].'\\\\M';
1255                             $sSQL = "select place_id from placex where parent_place_id in (".$sPlaceIDs.") and transliteration(housenumber) ~* E'".$sHouseNumberRegex."'";
1256                             if (sizeof($this->aExcludePlaceIDs)) {
1257                                 $sSQL .= " and place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1258                             }
1259                             $sSQL .= " limit $this->iLimit";
1260                             if (CONST_Debug) var_dump($sSQL);
1261                             $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1262                             
1263                             // if nothing found, search in the interpolation line table
1264                             if (!sizeof($aPlaceIDs)) {
1265                                 // do we need to use transliteration and the regex for housenumbers???
1266                                 //new query for lines, not housenumbers anymore
1267                                 if ($searchedHousenumber%2 == 0) {
1268                                     //if housenumber is even, look for housenumber in streets with interpolationtype even or all
1269                                     $sSQL = "select distinct place_id from location_property_osmline where parent_place_id in (".$sPlaceIDs.") and (interpolationtype='even' or interpolationtype='all') and ".$searchedHousenumber.">=startnumber and ".$searchedHousenumber."<=endnumber";
1270                                 } else {
1271                                     //look for housenumber in streets with interpolationtype odd or all
1272                                     $sSQL = "select distinct place_id from location_property_osmline where parent_place_id in (".$sPlaceIDs.") and (interpolationtype='odd' or interpolationtype='all') and ".$searchedHousenumber.">=startnumber and ".$searchedHousenumber."<=endnumber";
1273                                 }
1274
1275                                 if (sizeof($this->aExcludePlaceIDs)) {
1276                                     $sSQL .= " and place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1277                                 }
1278                                 //$sSQL .= " limit $this->iLimit";
1279                                 if (CONST_Debug) var_dump($sSQL);
1280                                 //get place IDs
1281                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL, 0));
1282                             }
1283                                 
1284                             // If nothing found try the aux fallback table
1285                             if (CONST_Use_Aux_Location_data && !sizeof($aPlaceIDs)) {
1286                                 $sSQL = "select place_id from location_property_aux where parent_place_id in (".$sPlaceIDs.") and housenumber = '".pg_escape_string($aSearch['sHouseNumber'])."'";
1287                                 if (sizeof($this->aExcludePlaceIDs)) {
1288                                     $sSQL .= " and parent_place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1289                                 }
1290                                 //$sSQL .= " limit $this->iLimit";
1291                                 if (CONST_Debug) var_dump($sSQL);
1292                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1293                             }
1294
1295                             //if nothing was found in placex or location_property_aux, then search in Tiger data for this housenumber(location_property_tiger)
1296                             if (CONST_Use_US_Tiger_Data && !sizeof($aPlaceIDs)) {
1297                                 //new query for lines, not housenumbers anymore
1298                                 if ($searchedHousenumber%2 == 0) {
1299                                     //if housenumber is even, look for housenumber in streets with interpolationtype even or all
1300                                     $sSQL = "select distinct place_id from location_property_tiger where parent_place_id in (".$sPlaceIDs.") and (interpolationtype='even' or interpolationtype='all') and ".$searchedHousenumber.">=startnumber and ".$searchedHousenumber."<=endnumber";
1301                                 } else {
1302                                     //look for housenumber in streets with interpolationtype odd or all
1303                                     $sSQL = "select distinct place_id from location_property_tiger where parent_place_id in (".$sPlaceIDs.") and (interpolationtype='odd' or interpolationtype='all') and ".$searchedHousenumber.">=startnumber and ".$searchedHousenumber."<=endnumber";
1304                                 }
1305
1306                                 if (sizeof($this->aExcludePlaceIDs)) {
1307                                     $sSQL .= " and place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1308                                 }
1309                                 //$sSQL .= " limit $this->iLimit";
1310                                 if (CONST_Debug) var_dump($sSQL);
1311                                 //get place IDs
1312                                 $aPlaceIDs = chksql($this->oDB->getCol($sSQL, 0));
1313                             }
1314
1315                             // Fallback to the road (if no housenumber was found)
1316                             if (!sizeof($aPlaceIDs) && preg_match('/[0-9]+/', $aSearch['sHouseNumber'])) {
1317                                 $aPlaceIDs = $aRoadPlaceIDs;
1318                                 //set to -1, if no housenumbers were found
1319                                 $searchedHousenumber = -1;
1320                             }
1321                             //else: housenumber was found, remains saved in searchedHousenumber
1322                         }
1323
1324
1325                         if ($aSearch['sClass'] && sizeof($aPlaceIDs)) {
1326                             $sPlaceIDs = join(',', $aPlaceIDs);
1327                             $aClassPlaceIDs = array();
1328
1329                             if (!$aSearch['sOperator'] || $aSearch['sOperator'] == 'name') {
1330                                 // If they were searching for a named class (i.e. 'Kings Head pub') then we might have an extra match
1331                                 $sSQL = "select place_id from placex where place_id in ($sPlaceIDs) and class='".$aSearch['sClass']."' and type='".$aSearch['sType']."'";
1332                                 $sSQL .= " and linked_place_id is null";
1333                                 if ($sCountryCodesSQL) $sSQL .= " and calculated_country_code in ($sCountryCodesSQL)";
1334                                 $sSQL .= " order by rank_search asc limit $this->iLimit";
1335                                 if (CONST_Debug) var_dump($sSQL);
1336                                 $aClassPlaceIDs = chksql($this->oDB->getCol($sSQL));
1337                             }
1338
1339                             if (!$aSearch['sOperator'] || $aSearch['sOperator'] == 'near') { // & in
1340                                 $sSQL = "select count(*) from pg_tables where tablename = 'place_classtype_".$aSearch['sClass']."_".$aSearch['sType']."'";
1341                                 $bCacheTable = chksql($this->oDB->getOne($sSQL));
1342
1343                                 $sSQL = "select min(rank_search) from placex where place_id in ($sPlaceIDs)";
1344
1345                                 if (CONST_Debug) var_dump($sSQL);
1346                                 $this->iMaxRank = ((int)chksql($this->oDB->getOne($sSQL)));
1347
1348                                 // For state / country level searches the normal radius search doesn't work very well
1349                                 $sPlaceGeom = false;
1350                                 if ($this->iMaxRank < 9 && $bCacheTable) {
1351                                     // Try and get a polygon to search in instead
1352                                     $sSQL = "select geometry from placex where place_id in ($sPlaceIDs) and rank_search < $this->iMaxRank + 5 and st_geometrytype(geometry) in ('ST_Polygon','ST_MultiPolygon') order by rank_search asc limit 1";
1353                                     if (CONST_Debug) var_dump($sSQL);
1354                                     $sPlaceGeom = chksql($this->oDB->getOne($sSQL));
1355                                 }
1356
1357                                 if ($sPlaceGeom) {
1358                                     $sPlaceIDs = false;
1359                                 } else {
1360                                     $this->iMaxRank += 5;
1361                                     $sSQL = "select place_id from placex where place_id in ($sPlaceIDs) and rank_search < $this->iMaxRank";
1362                                     if (CONST_Debug) var_dump($sSQL);
1363                                     $aPlaceIDs = chksql($this->oDB->getCol($sSQL));
1364                                     $sPlaceIDs = join(',', $aPlaceIDs);
1365                                 }
1366
1367                                 if ($sPlaceIDs || $sPlaceGeom) {
1368                                     $fRange = 0.01;
1369                                     if ($bCacheTable) {
1370                                         // More efficient - can make the range bigger
1371                                         $fRange = 0.05;
1372
1373                                         $sOrderBySQL = '';
1374                                         if ($sNearPointSQL) $sOrderBySQL = "ST_Distance($sNearPointSQL, l.centroid)";
1375                                         elseif ($sPlaceIDs) $sOrderBySQL = "ST_Distance(l.centroid, f.geometry)";
1376                                         elseif ($sPlaceGeom) $sOrderBysSQL = "ST_Distance(st_centroid('".$sPlaceGeom."'), l.centroid)";
1377
1378                                         $sSQL = "select distinct l.place_id".($sOrderBySQL?','.$sOrderBySQL:'')." from place_classtype_".$aSearch['sClass']."_".$aSearch['sType']." as l";
1379                                         if ($sCountryCodesSQL) $sSQL .= " join placex as lp using (place_id)";
1380                                         if ($sPlaceIDs) {
1381                                             $sSQL .= ",placex as f where ";
1382                                             $sSQL .= "f.place_id in ($sPlaceIDs) and ST_DWithin(l.centroid, f.centroid, $fRange) ";
1383                                         }
1384                                         if ($sPlaceGeom) {
1385                                             $sSQL .= " where ";
1386                                             $sSQL .= "ST_Contains('".$sPlaceGeom."', l.centroid) ";
1387                                         }
1388                                         if (sizeof($this->aExcludePlaceIDs)) {
1389                                             $sSQL .= " and l.place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1390                                         }
1391                                         if ($sCountryCodesSQL) $sSQL .= " and lp.calculated_country_code in ($sCountryCodesSQL)";
1392                                         if ($sOrderBySQL) $sSQL .= "order by ".$sOrderBySQL." asc";
1393                                         if ($this->iOffset) $sSQL .= " offset $this->iOffset";
1394                                         $sSQL .= " limit $this->iLimit";
1395                                         if (CONST_Debug) var_dump($sSQL);
1396                                         $aClassPlaceIDs = array_merge($aClassPlaceIDs, chksql($this->oDB->getCol($sSQL)));
1397                                     } else {
1398                                         if (isset($aSearch['fRadius']) && $aSearch['fRadius']) $fRange = $aSearch['fRadius'];
1399
1400                                         $sOrderBySQL = '';
1401                                         if ($sNearPointSQL) $sOrderBySQL = "ST_Distance($sNearPointSQL, l.geometry)";
1402                                         else $sOrderBySQL = "ST_Distance(l.geometry, f.geometry)";
1403
1404                                         $sSQL = "select distinct l.place_id".($sOrderBysSQL?','.$sOrderBysSQL:'')." from placex as l,placex as f where ";
1405                                         $sSQL .= "f.place_id in ( $sPlaceIDs) and ST_DWithin(l.geometry, f.centroid, $fRange) ";
1406                                         $sSQL .= "and l.class='".$aSearch['sClass']."' and l.type='".$aSearch['sType']."' ";
1407                                         if (sizeof($this->aExcludePlaceIDs)) {
1408                                             $sSQL .= " and l.place_id not in (".join(',', $this->aExcludePlaceIDs).")";
1409                                         }
1410                                         if ($sCountryCodesSQL) $sSQL .= " and l.calculated_country_code in ($sCountryCodesSQL)";
1411                                         if ($sOrderBy) $sSQL .= "order by ".$OrderBysSQL." asc";
1412                                         if ($this->iOffset) $sSQL .= " offset $this->iOffset";
1413                                         $sSQL .= " limit $this->iLimit";
1414                                         if (CONST_Debug) var_dump($sSQL);
1415                                         $aClassPlaceIDs = array_merge($aClassPlaceIDs, chksql($this->oDB->getCol($sSQL)));
1416                                     }
1417                                 }
1418                             }
1419                             $aPlaceIDs = $aClassPlaceIDs;
1420                         }
1421                     }
1422
1423                     if (CONST_Debug) {
1424                         echo "<br><b>Place IDs:</b> ";
1425                         var_Dump($aPlaceIDs);
1426                     }
1427
1428                     foreach ($aPlaceIDs as $iPlaceID) {
1429                         // array for placeID => -1 | Tiger housenumber
1430                         $aResultPlaceIDs[$iPlaceID] = $searchedHousenumber;
1431                     }
1432                     if ($iQueryLoop > 20) break;
1433                 }
1434
1435                 if (isset($aResultPlaceIDs) && sizeof($aResultPlaceIDs) && ($this->iMinAddressRank != 0 || $this->iMaxAddressRank != 30)) {
1436                     // Need to verify passes rank limits before dropping out of the loop (yuk!)
1437                     // reduces the number of place ids, like a filter
1438                     // rank_address is 30 for interpolated housenumbers
1439                     $sSQL = "select place_id from placex where place_id in (".join(',', array_keys($aResultPlaceIDs)).") ";
1440                     $sSQL .= "and (placex.rank_address between $this->iMinAddressRank and $this->iMaxAddressRank ";
1441                     if (14 >= $this->iMinAddressRank && 14 <= $this->iMaxAddressRank) $sSQL .= " OR (extratags->'place') = 'city'";
1442                     if ($this->aAddressRankList) $sSQL .= " OR placex.rank_address in (".join(',', $this->aAddressRankList).")";
1443                     if (CONST_Use_US_Tiger_Data) {
1444                         $sSQL .= ") UNION select place_id from location_property_tiger where place_id in (".join(',', array_keys($aResultPlaceIDs)).") ";
1445                         $sSQL .= "and (30 between $this->iMinAddressRank and $this->iMaxAddressRank ";
1446                         if ($this->aAddressRankList) $sSQL .= " OR 30 in (".join(',', $this->aAddressRankList).")";
1447                     }
1448                     $sSQL .= ") UNION select place_id from location_property_osmline where place_id in (".join(',', array_keys($aResultPlaceIDs)).")";
1449                     $sSQL .= " and (30 between $this->iMinAddressRank and $this->iMaxAddressRank)";
1450                     if (CONST_Debug) var_dump($sSQL);
1451                     $aFilteredPlaceIDs = chksql($this->oDB->getCol($sSQL));
1452                     $tempIDs = array();
1453                     foreach ($aFilteredPlaceIDs as $placeID) {
1454                         $tempIDs[$placeID] = $aResultPlaceIDs[$placeID];  //assign housenumber to placeID
1455                     }
1456                     $aResultPlaceIDs = $tempIDs;
1457                 }
1458
1459                 //exit;
1460                 if (isset($aResultPlaceIDs) && sizeof($aResultPlaceIDs)) break;
1461                 if ($iGroupLoop > 4) break;
1462                 if ($iQueryLoop > 30) break;
1463             }
1464
1465             // Did we find anything?
1466             if (isset($aResultPlaceIDs) && sizeof($aResultPlaceIDs)) {
1467                 $aSearchResults = $this->getDetails($aResultPlaceIDs);
1468             }
1469         } else {
1470             // Just interpret as a reverse geocode
1471             $oReverse = new ReverseGeocode($this->oDB);
1472             $oReverse->setZoom(18);
1473
1474             $aLookup = $oReverse->lookup(
1475                 (float)$this->aNearPoint[0],
1476                 (float)$this->aNearPoint[1],
1477                 false
1478             );
1479
1480             if (CONST_Debug) var_dump("Reverse search", $aLookup);
1481
1482             if ($aLookup['place_id']) {
1483                 $aSearchResults = $this->getDetails(array($aLookup['place_id'] => -1));
1484                 $aResultPlaceIDs[$aLookup['place_id']] = -1;
1485             } else {
1486                 $aSearchResults = array();
1487             }
1488         }
1489
1490         // No results? Done
1491         if (!sizeof($aSearchResults)) {
1492             if ($this->bFallback) {
1493                 if ($this->fallbackStructuredQuery()) {
1494                     return $this->lookup();
1495                 }
1496             }
1497
1498             return array();
1499         }
1500
1501         $aClassType = getClassTypesWithImportance();
1502         $aRecheckWords = preg_split('/\b[\s,\\-]*/u', $sQuery);
1503         foreach ($aRecheckWords as $i => $sWord) {
1504             if (!preg_match('/\pL/', $sWord)) unset($aRecheckWords[$i]);
1505         }
1506
1507         if (CONST_Debug) {
1508             echo '<i>Recheck words:<\i>';
1509             var_dump($aRecheckWords);
1510         }
1511
1512         $oPlaceLookup = new PlaceLookup($this->oDB);
1513         $oPlaceLookup->setIncludePolygonAsPoints($this->bIncludePolygonAsPoints);
1514         $oPlaceLookup->setIncludePolygonAsText($this->bIncludePolygonAsText);
1515         $oPlaceLookup->setIncludePolygonAsGeoJSON($this->bIncludePolygonAsGeoJSON);
1516         $oPlaceLookup->setIncludePolygonAsKML($this->bIncludePolygonAsKML);
1517         $oPlaceLookup->setIncludePolygonAsSVG($this->bIncludePolygonAsSVG);
1518         $oPlaceLookup->setPolygonSimplificationThreshold($this->fPolygonSimplificationThreshold);
1519
1520         foreach ($aSearchResults as $iResNum => $aResult) {
1521             // Default
1522             $fDiameter = getResultDiameter($aResult);
1523
1524             $aOutlineResult = $oPlaceLookup->getOutlines($aResult['place_id'], $aResult['lon'], $aResult['lat'], $fDiameter/2);
1525             if ($aOutlineResult) {
1526                 $aResult = array_merge($aResult, $aOutlineResult);
1527             }
1528             
1529             if ($aResult['extra_place'] == 'city') {
1530                 $aResult['class'] = 'place';
1531                 $aResult['type'] = 'city';
1532                 $aResult['rank_search'] = 16;
1533             }
1534
1535             // Is there an icon set for this type of result?
1536             if (isset($aClassType[$aResult['class'].':'.$aResult['type']]['icon'])
1537                 && $aClassType[$aResult['class'].':'.$aResult['type']]['icon']
1538             ) {
1539                 $aResult['icon'] = CONST_Website_BaseURL.'images/mapicons/'.$aClassType[$aResult['class'].':'.$aResult['type']]['icon'].'.p.20.png';
1540             }
1541
1542             if (isset($aClassType[$aResult['class'].':'.$aResult['type'].':'.$aResult['admin_level']]['label'])
1543                 && $aClassType[$aResult['class'].':'.$aResult['type'].':'.$aResult['admin_level']]['label']
1544             ) {
1545                 $aResult['label'] = $aClassType[$aResult['class'].':'.$aResult['type'].':'.$aResult['admin_level']]['label'];
1546             } elseif (isset($aClassType[$aResult['class'].':'.$aResult['type']]['label'])
1547                 && $aClassType[$aResult['class'].':'.$aResult['type']]['label']
1548             ) {
1549                 $aResult['label'] = $aClassType[$aResult['class'].':'.$aResult['type']]['label'];
1550             }
1551             // if tag '&addressdetails=1' is set in query
1552             if ($this->bIncludeAddressDetails) {
1553                 // getAddressDetails() is defined in lib.php and uses the SQL function get_addressdata in functions.sql
1554                 $aResult['address'] = getAddressDetails($this->oDB, $sLanguagePrefArraySQL, $aResult['place_id'], $aResult['country_code'], $aResultPlaceIDs[$aResult['place_id']]);
1555                 if ($aResult['extra_place'] == 'city' && !isset($aResult['address']['city'])) {
1556                     $aResult['address'] = array_merge(array('city' => array_values($aResult['address'])[0]), $aResult['address']);
1557                 }
1558             }
1559
1560             if ($this->bIncludeExtraTags) {
1561                 if ($aResult['extra']) {
1562                     $aResult['sExtraTags'] = json_decode($aResult['extra']);
1563                 } else {
1564                     $aResult['sExtraTags'] = (object) array();
1565                 }
1566             }
1567
1568             if ($this->bIncludeNameDetails) {
1569                 if ($aResult['names']) {
1570                     $aResult['sNameDetails'] = json_decode($aResult['names']);
1571                 } else {
1572                     $aResult['sNameDetails'] = (object) array();
1573                 }
1574             }
1575
1576             // Adjust importance for the number of exact string matches in the result
1577             $aResult['importance'] = max(0.001, $aResult['importance']);
1578             $iCountWords = 0;
1579             $sAddress = $aResult['langaddress'];
1580             foreach ($aRecheckWords as $i => $sWord) {
1581                 if (stripos($sAddress, $sWord)!==false) {
1582                     $iCountWords++;
1583                     if (preg_match("/(^|,)\s*".preg_quote($sWord, '/')."\s*(,|$)/", $sAddress)) $iCountWords += 0.1;
1584                 }
1585             }
1586
1587             $aResult['importance'] = $aResult['importance'] + ($iCountWords*0.1); // 0.1 is a completely arbitrary number but something in the range 0.1 to 0.5 would seem right
1588
1589             $aResult['name'] = $aResult['langaddress'];
1590             // secondary ordering (for results with same importance (the smaller the better):
1591             // - approximate importance of address parts
1592             $aResult['foundorder'] = -$aResult['addressimportance']/10;
1593             // - number of exact matches from the query
1594             if (isset($this->exactMatchCache[$aResult['place_id']])) {
1595                 $aResult['foundorder'] -= $this->exactMatchCache[$aResult['place_id']];
1596             } elseif (isset($this->exactMatchCache[$aResult['parent_place_id']])) {
1597                 $aResult['foundorder'] -= $this->exactMatchCache[$aResult['parent_place_id']];
1598             }
1599             // - importance of the class/type
1600             if (isset($aClassType[$aResult['class'].':'.$aResult['type']]['importance'])
1601                 && $aClassType[$aResult['class'].':'.$aResult['type']]['importance']
1602             ) {
1603                 $aResult['foundorder'] += 0.0001 * $aClassType[$aResult['class'].':'.$aResult['type']]['importance'];
1604             } else {
1605                 $aResult['foundorder'] += 0.01;
1606             }
1607             if (CONST_Debug) var_dump($aResult);
1608             $aSearchResults[$iResNum] = $aResult;
1609         }
1610         uasort($aSearchResults, 'byImportance');
1611
1612         $aOSMIDDone = array();
1613         $aClassTypeNameDone = array();
1614         $aToFilter = $aSearchResults;
1615         $aSearchResults = array();
1616
1617         $bFirst = true;
1618         foreach ($aToFilter as $iResNum => $aResult) {
1619             $this->aExcludePlaceIDs[$aResult['place_id']] = $aResult['place_id'];
1620             if ($bFirst) {
1621                 $fLat = $aResult['lat'];
1622                 $fLon = $aResult['lon'];
1623                 if (isset($aResult['zoom'])) $iZoom = $aResult['zoom'];
1624                 $bFirst = false;
1625             }
1626             if (!$this->bDeDupe || (!isset($aOSMIDDone[$aResult['osm_type'].$aResult['osm_id']])
1627                 && !isset($aClassTypeNameDone[$aResult['osm_type'].$aResult['class'].$aResult['type'].$aResult['name'].$aResult['admin_level']]))
1628             ) {
1629                 $aOSMIDDone[$aResult['osm_type'].$aResult['osm_id']] = true;
1630                 $aClassTypeNameDone[$aResult['osm_type'].$aResult['class'].$aResult['type'].$aResult['name'].$aResult['admin_level']] = true;
1631                 $aSearchResults[] = $aResult;
1632             }
1633
1634             // Absolute limit on number of results
1635             if (sizeof($aSearchResults) >= $this->iFinalLimit) break;
1636         }
1637
1638         return $aSearchResults;
1639     } // end lookup()
1640 } // end class