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
![]() |
|||
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 |