dvdoug /
PHPCoord
| 1 | <?php |
||
| 2 | |||
| 3 | /** |
||
| 4 | * PHPCoord. |
||
| 5 | * |
||
| 6 | * @author Doug Wright |
||
| 7 | */ |
||
| 8 | declare(strict_types=1); |
||
| 9 | |||
| 10 | namespace PHPCoord\Geometry; |
||
| 11 | |||
| 12 | use Composer\InstalledVersions; |
||
| 13 | use PHPCoord\CoordinateOperation\GeographicValue; |
||
| 14 | use PHPCoord\Datum\Datum; |
||
| 15 | use PHPCoord\Geometry\GeoJSON\GeoJSON; |
||
| 16 | use PHPCoord\UnitOfMeasure\Angle\Angle; |
||
| 17 | use PHPCoord\UnitOfMeasure\Angle\Degree; |
||
| 18 | use UnhandledMatchError; |
||
| 19 | |||
| 20 | use function assert; |
||
| 21 | use function count; |
||
| 22 | use function implode; |
||
| 23 | use function usort; |
||
| 24 | use function in_array; |
||
| 25 | use function array_keys; |
||
| 26 | use function str_replace; |
||
| 27 | use function array_push; |
||
| 28 | use function array_unique; |
||
| 29 | use function file_exists; |
||
| 30 | |||
| 31 | class BoundingArea |
||
| 32 | { |
||
| 33 | /** |
||
| 34 | * @var Polygon[] |
||
| 35 | */ |
||
| 36 | protected array $polygons; |
||
| 37 | |||
| 38 | protected string $region; |
||
| 39 | |||
| 40 | protected bool $longitudeWrapAroundChecked = false; |
||
| 41 | |||
| 42 | protected bool $longitudeExtendsFurtherThanMinus180 = false; |
||
| 43 | |||
| 44 | protected bool $longitudeExtendsFurtherThanPlus180 = false; |
||
| 45 | |||
| 46 | /** |
||
| 47 | * @var array<string, self> |
||
| 48 | */ |
||
| 49 | private static array $cachedObjects = []; |
||
| 50 | |||
| 51 | /** |
||
| 52 | * @var array{0: Angle, 1: Angle} |
||
| 53 | */ |
||
| 54 | private array $pointInside; |
||
| 55 | |||
| 56 | /** |
||
| 57 | * @var array<int, array{0: Angle, 1: Angle}> |
||
| 58 | */ |
||
| 59 | private array $centre = []; |
||
| 60 | |||
| 61 | /** |
||
| 62 | * @param Polygon[] $polygons |
||
| 63 | 714 | */ |
|
| 64 | protected function __construct(array $polygons, string $region) |
||
| 65 | { |
||
| 66 | 714 | // put largest polygon (outer ring size) first |
|
| 67 | 714 | usort($polygons, fn (Polygon $polygonA, Polygon $polygonB) => count($polygonB->coordinates[0]->coordinates) <=> count($polygonA->coordinates[0]->coordinates)); |
|
| 68 | 714 | $this->polygons = $polygons; |
|
| 69 | $this->region = $region; |
||
| 70 | } |
||
| 71 | 274 | ||
| 72 | public function getRegion(): string |
||
| 73 | 274 | { |
|
| 74 | return $this->region; |
||
| 75 | } |
||
| 76 | |||
| 77 | /** |
||
| 78 | * Vertices in GeoJSON-type format (an array of polygons, which is an array of rings which is an array of long,lat points). |
||
| 79 | * @param Polygon[] $polygons |
||
| 80 | * @param RegionMap::REGION_* $region |
||
| 81 | 687 | */ |
|
| 82 | public static function createFromPolygons(array $polygons, string $region): self |
||
| 83 | 687 | { |
|
| 84 | return new self($polygons, $region); |
||
| 85 | } |
||
| 86 | 54 | ||
| 87 | public static function createWorld(): self |
||
| 88 | 54 | { |
|
| 89 | 54 | return new self( |
|
| 90 | 54 | [ |
|
| 91 | 54 | new Polygon( |
|
| 92 | 54 | new LinearRing( |
|
| 93 | 54 | new Position(-180, -90), |
|
| 94 | 54 | new Position(-180, 90), |
|
| 95 | 54 | new Position(180, 90), |
|
| 96 | 54 | new Position(180, -90), |
|
| 97 | 54 | new Position(-180, -90), |
|
| 98 | 54 | ) |
|
| 99 | 54 | ), |
|
| 100 | 54 | ], |
|
| 101 | 54 | RegionMap::REGION_GLOBAL |
|
| 102 | ); |
||
| 103 | } |
||
| 104 | |||
| 105 | /** |
||
| 106 | * @internal |
||
| 107 | * @param string[] $extentCodes |
||
| 108 | 779 | */ |
|
| 109 | public static function createFromExtentCodes(array $extentCodes, bool $boundingBoxOnly = false): self |
||
| 110 | 779 | { |
|
| 111 | 779 | $cacheKey = implode('|', $extentCodes) . ($boundingBoxOnly ? 'bboxOnly' : ''); |
|
| 112 | 597 | if (!isset(self::$cachedObjects[$cacheKey])) { |
|
| 113 | $regions = []; |
||
| 114 | |||
| 115 | 597 | /** @var Polygon[] $extents */ |
|
| 116 | 597 | $extents = []; |
|
| 117 | 597 | foreach ($extentCodes as $extentUrn) { |
|
| 118 | 597 | $region = RegionMap::EXTENTS[$extentUrn]; |
|
| 119 | $regions[] = $region; |
||
| 120 | 597 | ||
| 121 | 597 | $filename = str_replace('urn:ogc:def:area:EPSG::', '', $extentUrn) . '.json'; |
|
| 122 | 597 | $pathToExtent = __DIR__ . '/Extents/BoundingBoxOnly/' . $filename; |
|
| 123 | 127 | if (InstalledVersions::isInstalled(RegionMap::PACKAGES[$region])) { |
|
| 124 | 127 | $baseDir = InstalledVersions::getInstallPath(RegionMap::PACKAGES[$region]) . '/src/Geometry/Extents/'; |
|
| 125 | 126 | if ((!$boundingBoxOnly || $region === RegionMap::REGION_GLOBAL) && file_exists($baseDir . $filename)) { |
|
| 126 | $pathToExtent = $baseDir . $filename; |
||
| 127 | } |
||
| 128 | } |
||
| 129 | 597 | ||
| 130 | 597 | $extent = GeoJSON::readFile($pathToExtent); |
|
| 131 | 589 | match ($extent::class) { |
|
| 132 | 19 | Polygon::class => $extents[] = $extent, |
|
| 133 | MultiPolygon::class => array_push($extents, ...$extent->coordinates), |
||
| 134 | 597 | default => throw new UnhandledMatchError() |
|
| 135 | }; |
||
| 136 | } |
||
| 137 | 597 | ||
| 138 | assert(count(array_unique($regions)) === 1); |
||
| 139 | |||
| 140 | 597 | /** @var RegionMap::REGION_* $region */ |
|
| 141 | 597 | $region = $regions[0]; |
|
| 142 | $extentData = self::createFromPolygons($extents, $region); |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 143 | 597 | ||
| 144 | if (in_array('urn:ogc:def:area:EPSG::1352', $extentCodes)) { |
||
| 145 | $extentData->pointInside = [new Degree(60.202778), new Degree(11.083889)]; |
||
| 146 | } |
||
| 147 | 597 | ||
| 148 | self::$cachedObjects[$cacheKey] = $extentData; |
||
| 149 | } |
||
| 150 | 779 | ||
| 151 | return self::$cachedObjects[$cacheKey]; |
||
| 152 | } |
||
| 153 | 328 | ||
| 154 | public function containsPoint(GeographicValue $point): bool |
||
| 155 | 328 | { |
|
| 156 | 220 | if (!$this->longitudeWrapAroundChecked) { |
|
| 157 | 220 | $this->longitudeWrapAroundChecked = true; |
|
| 158 | $this->checkLongitudeWrapAround(); |
||
| 159 | } |
||
| 160 | 328 | ||
| 161 | 328 | $latitude = $point->getLongitude()->asDegrees()->getValue(); |
|
| 162 | $longitude = $point->getLatitude()->asDegrees()->getValue(); |
||
| 163 | 328 | ||
| 164 | 328 | $pointsToCheck = [ |
|
| 165 | 328 | [ |
|
| 166 | 328 | $latitude, |
|
| 167 | 328 | $longitude, |
|
| 168 | 328 | ], |
|
| 169 | ]; |
||
| 170 | 328 | ||
| 171 | 181 | if ($this->longitudeExtendsFurtherThanMinus180) { |
|
| 172 | 181 | $pointsToCheck[] = [ |
|
| 173 | 181 | $latitude - 360, |
|
| 174 | 181 | $longitude, |
|
| 175 | ]; |
||
| 176 | } |
||
| 177 | 328 | ||
| 178 | 173 | if ($this->longitudeExtendsFurtherThanPlus180) { |
|
| 179 | 173 | $pointsToCheck[] = [ |
|
| 180 | 173 | $latitude + 360, |
|
| 181 | 173 | $longitude, |
|
| 182 | ]; |
||
| 183 | } |
||
| 184 | |||
| 185 | /* |
||
| 186 | * @see https://observablehq.com/@tmcw/understanding-point-in-polygon |
||
| 187 | 328 | */ |
|
| 188 | 328 | foreach ($pointsToCheck as $pointToCheck) { |
|
| 189 | 328 | [$x, $y] = $pointToCheck; |
|
| 190 | 328 | foreach ($this->polygons as $polygon) { |
|
| 191 | $vertices = $polygon->coordinates[0]->coordinates; // this algo works on simple polygons (no holes) |
||
| 192 | 328 | ||
| 193 | 328 | $n = count($vertices); |
|
| 194 | 328 | $inside = false; |
|
| 195 | 328 | for ($i = 0, $j = $n - 1; $i < $n; $j = $i++) { |
|
| 196 | 328 | $xi = $vertices[$i]->x; |
|
| 197 | 328 | $yi = $vertices[$i]->y; |
|
| 198 | 328 | $xj = $vertices[$j]->x; |
|
| 199 | $yj = $vertices[$j]->y; |
||
| 200 | 328 | ||
| 201 | 328 | $intersect = (($yi > $y) !== ($yj > $y)) // horizontal ray from $y, intersects if vertices are on opposite sides of it |
|
| 202 | 328 | && ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi); |
|
| 203 | 319 | if ($intersect) { |
|
| 204 | $inside = !$inside; |
||
|
0 ignored issues
–
show
|
|||
| 205 | } |
||
| 206 | } |
||
| 207 | 328 | ||
| 208 | 319 | if ($inside) { |
|
| 209 | return true; |
||
| 210 | } |
||
| 211 | } |
||
| 212 | } |
||
| 213 | 108 | ||
| 214 | return false; |
||
| 215 | } |
||
| 216 | |||
| 217 | /** |
||
| 218 | * @internal used for testing |
||
| 219 | * @return array{0: Angle, 1: Angle} |
||
| 220 | 27 | */ |
|
| 221 | public function getPointInside(): array |
||
| 222 | 27 | { |
|
| 223 | 27 | if (!isset($this->pointInside)) { |
|
| 224 | 27 | foreach (array_keys($this->polygons) as $polygonId) { |
|
| 225 | 27 | $point = $this->getCentre($polygonId); |
|
| 226 | 27 | $this->pointInside = $point; |
|
| 227 | 27 | if ($this->containsPoint(new GeographicValue($point[0], $point[1], null, Datum::fromSRID(Datum::EPSG_WORLD_GEODETIC_SYSTEM_1984_ENSEMBLE)))) { |
|
| 228 | 27 | $this->pointInside = $point; |
|
| 229 | break; |
||
| 230 | } |
||
| 231 | } |
||
| 232 | } |
||
| 233 | 27 | ||
| 234 | return $this->pointInside; |
||
| 235 | } |
||
| 236 | |||
| 237 | /** |
||
| 238 | * @internal used for testing |
||
| 239 | * @return array{0: Angle, 1: Angle} |
||
| 240 | 27 | */ |
|
| 241 | protected function getCentre(int $polygonId): array |
||
| 242 | 27 | { |
|
| 243 | if (!isset($this->centre[$polygonId])) { |
||
| 244 | 27 | // Calculates the "centre" (centroid) of a polygon. |
|
| 245 | 27 | $vertices = $this->polygons[$polygonId]->coordinates[0]->coordinates; // only consider outer ring |
|
| 246 | 27 | $n = count($vertices) - 1; |
|
| 247 | $area = 0; |
||
| 248 | 27 | ||
| 249 | 27 | for ($i = 0; $i < $n; ++$i) { |
|
| 250 | $area += $vertices[$i]->x * $vertices[$i + 1]->y; |
||
| 251 | 27 | } |
|
| 252 | $area += $vertices[$n]->x * $vertices[0]->y; |
||
| 253 | 27 | ||
| 254 | 27 | for ($i = 0; $i < $n; ++$i) { |
|
| 255 | $area -= $vertices[$i + 1]->x * $vertices[$i]->y; |
||
| 256 | 27 | } |
|
| 257 | 27 | $area -= $vertices[0]->x * $vertices[$n]->y; |
|
| 258 | $area /= 2; |
||
| 259 | 27 | ||
| 260 | 27 | $latitude = 0; |
|
| 261 | $longitude = 0; |
||
| 262 | 27 | ||
| 263 | 27 | for ($i = 0; $i < $n; ++$i) { |
|
| 264 | 27 | $latitude += ($vertices[$i]->y + $vertices[$i + 1]->y) * ($vertices[$i]->x * $vertices[$i + 1]->y - $vertices[$i + 1]->x * $vertices[$i]->y); |
|
| 265 | $longitude += ($vertices[$i]->x + $vertices[$i + 1]->x) * ($vertices[$i]->x * $vertices[$i + 1]->y - $vertices[$i + 1]->x * $vertices[$i]->y); |
||
| 266 | 27 | } |
|
| 267 | 27 | $latitude = new Degree($latitude / 6 / $area); |
|
| 268 | $longitude = new Degree($longitude / 6 / $area); |
||
| 269 | 27 | ||
| 270 | $this->centre[$polygonId] = [$latitude, $longitude]; |
||
| 271 | } |
||
| 272 | 27 | ||
| 273 | return $this->centre[$polygonId]; |
||
| 274 | } |
||
| 275 | 220 | ||
| 276 | private function checkLongitudeWrapAround(): void |
||
| 277 | 220 | { |
|
| 278 | 220 | foreach ($this->polygons as $polygon) { |
|
| 279 | 220 | foreach ($polygon->coordinates as $ring) { |
|
| 280 | 220 | foreach ($ring->coordinates as $vertex) { |
|
| 281 | 73 | if ($vertex->x >= 180) { |
|
| 282 | $this->longitudeExtendsFurtherThanPlus180 = true; |
||
| 283 | 220 | } |
|
| 284 | 56 | if ($vertex->x <= -180) { |
|
| 285 | $this->longitudeExtendsFurtherThanMinus180 = true; |
||
| 286 | } |
||
| 287 | } |
||
| 288 | } |
||
| 289 | } |
||
| 290 | } |
||
| 291 | } |
||
| 292 |