1<?php 2 3/* 4 * This file is part of the GeoPHP package. 5 * Copyright (c) 2011 - 2016 Patrick Hayes and contributors 6 * 7 * For the full copyright and license information, please view the LICENSE 8 * file that was distributed with this source code. 9 */ 10namespace geoPHP; 11 12use geoPHP\Adapter\GeoHash; 13use geoPHP\Geometry\Collection; 14use geoPHP\Geometry\Geometry; 15use geoPHP\Geometry\GeometryCollection; 16 17// @codingStandardsIgnoreLine 18class geoPHP 19{ 20 // Earth radius constants in meters 21 22 /** WGS84 semi-major axis (a), aka equatorial radius */ 23 const EARTH_WGS84_SEMI_MAJOR_AXIS = 6378137.0; 24 /** WGS84 semi-minor axis (b), aka polar radius */ 25 const EARTH_WGS84_SEMI_MINOR_AXIS = 6356752.314245; 26 /** WGS84 inverse flattening */ 27 const EARTH_WGS84_FLATTENING = 298.257223563; 28 29 /** WGS84 semi-major axis (a), aka equatorial radius */ 30 const EARTH_GRS80_SEMI_MAJOR_AXIS = 6378137.0; 31 /** GRS80 semi-minor axis */ 32 const EARTH_GRS80_SEMI_MINOR_AXIS = 6356752.314140; 33 /** GRS80 inverse flattening */ 34 const EARTH_GRS80_FLATTENING = 298.257222100882711; 35 36 /** IUGG mean radius R1 = (2a + b) / 3 */ 37 const EARTH_MEAN_RADIUS = 6371008.8; 38 /** IUGG R2: Earth's authalic ("equal area") radius is the radius of a hypothetical perfect sphere 39 * which has the same surface area as the reference ellipsoid. */ 40 const EARTH_AUTHALIC_RADIUS = 6371007.2; 41 42 const CLASS_NAMESPACE = 'geoPHP\\'; 43 44 private static $adapterMap = [ 45 'wkt' => 'WKT', 46 'ewkt' => 'EWKT', 47 'wkb' => 'WKB', 48 'ewkb' => 'EWKB', 49 'json' => 'GeoJSON', 50 'geojson' => 'GeoJSON', 51 'kml' => 'KML', 52 'gpx' => 'GPX', 53 'georss' => 'GeoRSS', 54 'google_geocode' => 'GoogleGeocode', 55 'geohash' => 'GeoHash', 56 'twkb' => 'TWKB', 57 'osm' => 'OSM', 58 ]; 59 60 public static function getAdapterMap() 61 { 62 return self::$adapterMap; 63 } 64 65 private static $geometryList = [ 66 'point' => 'Point', 67 'linestring' => 'LineString', 68 'polygon' => 'Polygon', 69 'multipoint' => 'MultiPoint', 70 'multilinestring' => 'MultiLineString', 71 'multipolygon' => 'MultiPolygon', 72 'geometrycollection' => 'GeometryCollection', 73 ]; 74 75 public static function getGeometryList() 76 { 77 return self::$geometryList; 78 } 79 80 /** 81 * Converts data to Geometry using geo adapters 82 * 83 * If $data is an array, all passed in values will be combined into a single geometry 84 * 85 * @param mixed $data The data in any supported format, including geoPHP Geometry 86 * @var null|string $type Data type. Tries to detect if omitted 87 * @var mixed|null $otherArgs Arguments will be passed to the geo adapter 88 * 89 * @return Collection|Geometry 90 * @throws \Exception 91 */ 92 public static function load($data) 93 { 94 $args = func_get_args(); 95 96 $data = array_shift($args); 97 $type = count($args) && @array_key_exists($args[0], self::$adapterMap) ? strtolower(array_shift($args)) : null; 98 99 // Auto-detect type if needed 100 if (!$type) { 101 // If the user is trying to load a Geometry from a Geometry... Just pass it back 102 if (is_object($data)) { 103 if ($data instanceof Geometry) { 104 return $data; 105 } 106 } 107 108 $detected = geoPHP::detectFormat($data); 109 if (!$detected) { 110 throw new \Exception("Can not detect format"); 111 } 112 $format = explode(':', $detected); 113 $type = array_shift($format); 114 $args = $format ?: $args; 115 } 116 117 if (!array_key_exists($type, self::$adapterMap)) { 118 throw new \Exception('geoPHP could not find an adapter of type ' . htmlentities($type)); 119 } 120 $adapterType = self::CLASS_NAMESPACE . 'Adapter\\' . self::$adapterMap[$type]; 121 122 $adapter = new $adapterType(); 123 124 // Data is not an array, just pass it normally 125 if (!is_array($data)) { 126 $result = call_user_func_array([$adapter, "read"], array_merge([$data], $args)); 127 } else { // Data is an array, combine all passed in items into a single geometry 128 $geometries = []; 129 foreach ($data as $item) { 130 $geometries[] = call_user_func_array([$adapter, "read"], array_merge($item, $args)); 131 } 132 $result = geoPHP::buildGeometry($geometries); 133 } 134 135 return $result; 136 } 137 138 public static function geosInstalled($force = null) 139 { 140 static $geosInstalled = null; 141 if ($force !== null) { 142 $geosInstalled = $force; 143 } 144 if (getenv('GEOS_DISABLED') == 1) { 145 $geosInstalled = false; 146 } 147 if ($geosInstalled !== null) { 148 return $geosInstalled; 149 } 150 $geosInstalled = class_exists('GEOSGeometry', false); 151 152 return $geosInstalled; 153 } 154 155 /** 156 * @param \GEOSGeometry $geos 157 * @return Geometry|null 158 * @throws \Exception 159 * @codeCoverageIgnore 160 */ 161 public static function geosToGeometry($geos) 162 { 163 if (!geoPHP::geosInstalled()) { 164 return null; 165 } 166 /** @noinspection PhpUndefinedClassInspection */ 167 $wkbWriter = new \GEOSWKBWriter(); 168 /** @noinspection PhpUndefinedMethodInspection */ 169 $wkb = $wkbWriter->writeHEX($geos); 170 $geometry = geoPHP::load($wkb, 'wkb', true); 171 if ($geometry) { 172 $geometry->setGeos($geos); 173 return $geometry; 174 } 175 176 return null; 177 } 178 179 /** 180 * Reduce a geometry, or an array of geometries, into their 'lowest' available common geometry. 181 * For example a GeometryCollection of only points will become a MultiPoint 182 * A multi-point containing a single point will return a point. 183 * An array of geometries can be passed and they will be compiled into a single geometry 184 * 185 * @param Geometry|Geometry[]|GeometryCollection|GeometryCollection[] $geometries 186 * @return Geometry|false 187 */ 188 public static function geometryReduce($geometries) 189 { 190 if (empty($geometries)) { 191 return false; 192 } 193 /* 194 * If it is a single geometry 195 */ 196 if ($geometries instanceof Geometry) { 197 // If the geometry cannot even theoretically be reduced more, then pass it back 198 $singleGeometries = ['Point', 'LineString', 'Polygon']; 199 if (in_array($geometries->geometryType(), $singleGeometries)) { 200 return $geometries; 201 } 202 203 // If it is a multi-geometry, check to see if it just has one member 204 // If it does, then pass the member, if not, then just pass back the geometry 205 if (strpos($geometries->geometryType(), 'Multi') === 0) { 206 $components = $geometries->getComponents(); 207 if (count($components) == 1) { 208 return $components[0]; 209 } else { 210 return $geometries; 211 } 212 } 213 } elseif (is_array($geometries) && count($geometries) == 1) { 214 // If it's an array of one, then just parse the one 215 return geoPHP::geometryReduce(array_shift($geometries)); 216 } 217 218 if (!is_array($geometries)) { 219 $geometries = [$geometries]; 220 } 221 /** 222 * So now we either have an array of geometries 223 * @var Geometry[]|GeometryCollection[] $geometries 224 */ 225 226 $reducedGeometries = []; 227 $geometryTypes = []; 228 self::explodeCollections($geometries, $reducedGeometries, $geometryTypes); 229 230 $geometryTypes = array_unique($geometryTypes); 231 if (empty($geometryTypes)) { 232 return false; 233 } 234 if (count($geometryTypes) == 1) { 235 if (count($reducedGeometries) == 1) { 236 return $reducedGeometries[0]; 237 } else { 238 $class = self::CLASS_NAMESPACE . 239 'Geometry\\' . 240 (strstr($geometryTypes[0], 'Multi') ? '' : 'Multi') . 241 $geometryTypes[0]; 242 return new $class($reducedGeometries); 243 } 244 } else { 245 return new GeometryCollection($reducedGeometries); 246 } 247 } 248 249 /** 250 * @param Geometry[]|GeometryCollection[] $unreduced 251 */ 252 private static function explodeCollections($unreduced, &$reduced, &$types) 253 { 254 foreach ($unreduced as $item) { 255 if ($item->geometryType() == 'GeometryCollection' || strpos($item->geometryType(), 'Multi') === 0) { 256 self::explodeCollections($item->getComponents(), $reduced, $types); 257 } else { 258 $reduced[] = $item; 259 $types[] = $item->geometryType(); 260 } 261 } 262 } 263 264 /** 265 * Build an appropriate Geometry, MultiGeometry, or GeometryCollection to contain the Geometries in it. 266 * 267 * @see geos::geom::GeometryFactory::buildGeometry 268 * 269 * @param Geometry|Geometry[]|GeometryCollection|GeometryCollection[] $geometries 270 * @return Geometry A Geometry of the "smallest", "most type-specific" class that can contain the elements. 271 * @throws \Exception 272 */ 273 public static function buildGeometry($geometries) 274 { 275 if (empty($geometries)) { 276 return new GeometryCollection(); 277 } 278 279 /* If it is a single geometry */ 280 if ($geometries instanceof Geometry) { 281 return $geometries; 282 } elseif (!is_array($geometries)) { 283 return null; 284 //FIXME should be: throw new \Exception('Input is not a Geometry or array of Geometries'); 285 } elseif (count($geometries) == 1) { 286 // If it's an array of one, then just parse the one 287 return geoPHP::buildGeometry(array_shift($geometries)); 288 } 289 290 /** 291 * So now we either have an array of geometries 292 * @var Geometry[]|GeometryCollection[] $geometries 293 */ 294 295 $geometryTypes = []; 296 $hasData = false; 297 foreach ($geometries as $item) { 298 if ($item) { 299 $geometryTypes[] = $item->geometryType(); 300 if ($item->getData() !== null) { 301 $hasData = true; 302 } 303 } 304 } 305 $geometryTypes = array_unique($geometryTypes); 306 if (empty($geometryTypes)) { 307 return null; 308 // FIXME normally it never happens. Should be refactored 309 } 310 if (count($geometryTypes) == 1 && !$hasData) { 311 if ($geometryTypes[0] === Geometry::GEOMETRY_COLLECTION) { 312 return new GeometryCollection($geometries); 313 } 314 if (count($geometries) == 1) { 315 return $geometries[0]; 316 } else { 317 $newType = (strpos($geometryTypes[0], 'Multi') !== false ? '' : 'Multi') . $geometryTypes[0]; 318 foreach ($geometries as $geometry) { 319 if ($geometry->isEmpty()) { 320 return new GeometryCollection($geometries); 321 } 322 } 323 $class = self::CLASS_NAMESPACE . 'Geometry\\' . $newType; 324 return new $class($geometries); 325 } 326 } else { 327 return new GeometryCollection($geometries); 328 } 329 } 330 331 /** 332 * Detect a format given a value. This function is meant to be SPEEDY. 333 * It could make a mistake in XML detection if you are mixing or using namespaces in weird ways 334 * (ie, KML inside an RSS feed) 335 * 336 * @param mixed $input 337 * 338 * @return string|false 339 */ 340 public static function detectFormat(&$input) 341 { 342 $input = (string) $input; 343 $mem = fopen('php://memory', 'x+'); 344 fwrite($mem, $input, 11); // Write 11 bytes - we can detect the vast majority of formats in the first 11 bytes 345 fseek($mem, 0); 346 347 $bin = fread($mem, 11); 348 $bytes = unpack("c*", $bin); 349 350 // If bytes is empty, then we were passed empty input 351 if (empty($bytes)) { 352 return false; 353 } 354 355 // First char is a tab, space or carriage-return. trim it and try again 356 if ($bytes[1] == 9 || $bytes[1] == 10 || $bytes[1] == 32) { 357 $input = ltrim($input); 358 return geoPHP::detectFormat($input); 359 } 360 361 // Detect WKB or EWKB -- first byte is 1 (little endian indicator) 362 if ($bytes[1] == 1 || $bytes[1] == 0) { 363 $wkbType = current(unpack($bytes[1] == 1 ? 'V' : 'N', substr($bin, 1, 4))); 364 if (array_search($wkbType & 0xF, Adapter\WKB::$typeMap)) { 365 // If SRID byte is TRUE (1), it's EWKB 366 if (($wkbType & Adapter\WKB::SRID_MASK) === Adapter\WKB::SRID_MASK) { 367 return 'ewkb'; 368 } else { 369 return 'wkb'; 370 } 371 } 372 } 373 374 // Detect HEX encoded WKB or EWKB (PostGIS format) -- first byte is 48, second byte is 49 (hex '01' => first-byte = 1) 375 // The shortest possible WKB string (LINESTRING EMPTY) is 18 hex-chars (9 encoded bytes) long 376 // This differentiates it from a geohash, which is always shorter than 13 characters. 377 if ($bytes[1] == 48 && ($bytes[2] == 49 || $bytes[2] == 48) && strlen($input) > 12) { 378 if ((current(unpack($bytes[2] == 49 ? 'V' : 'N', hex2bin(substr($bin, 2, 8)))) & Adapter\WKB::SRID_MASK) == Adapter\WKB::SRID_MASK) { 379 return 'ewkb:true'; 380 } else { 381 return 'wkb:true'; 382 } 383 } 384 385 // Detect GeoJSON - first char starts with { 386 if ($bytes[1] == 123) { 387 return 'json'; 388 } 389 390 // Detect EWKT - strats with "SRID=number;" 391 if (substr($input, 0, 5) === 'SRID=') { 392 return 'ewkt'; 393 } 394 395 // Detect WKT - starts with a geometry type name 396 if (Adapter\WKT::isWktType(strstr($input, ' ', true))) { 397 return 'wkt'; 398 } 399 400 // Detect XML -- first char is < 401 if ($bytes[1] == 60) { 402 // grab the first 1024 characters 403 $string = substr($input, 0, 1024); 404 if (strpos($string, '<kml') !== false) { 405 return 'kml'; 406 } 407 if (strpos($string, '<coordinate') !== false) { 408 return 'kml'; 409 } 410 if (strpos($string, '<gpx') !== false) { 411 return 'gpx'; 412 } 413 if (strpos($string, '<osm ') !== false) { 414 return 'osm'; 415 } 416 if (preg_match('/<[a-z]{3,20}>/', $string) !== false) { 417 return 'georss'; 418 } 419 } 420 421 // We need an 8 byte string for geohash and unpacked WKB / WKT 422 fseek($mem, 0); 423 $string = trim(fread($mem, 8)); 424 425 // Detect geohash - geohash ONLY contains lowercase chars and numerics 426 preg_match('/[' . GeoHash::$characterTable . ']+/', $string, $matches); 427 if (isset($matches[0]) && $matches[0] == $string && strlen($input) <= 13) { 428 return 'geohash'; 429 } 430 431 preg_match('/^[a-f0-9]+$/', $string, $matches); 432 if (isset($matches[0])) { 433 return 'twkb:true'; 434 } else { 435 return 'twkb'; 436 } 437 438 // What do you get when you cross an elephant with a rhino? 439 // http://youtu.be/RCBn5J83Poc 440 } 441} 442