Repository: jsor/geokit Branch: main Commit: 67b44ef6f3e8 Files: 27 Total size: 72.1 KB Directory structure: gitextract_40il9h5a/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── static.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── psalm.xml ├── src/ │ ├── BoundingBox.php │ ├── Distance.php │ ├── Earth.php │ ├── Exception/ │ │ ├── Exception.php │ │ ├── InvalidArgumentException.php │ │ ├── LogicException.php │ │ ├── MissingCoordinateException.php │ │ └── RuntimeException.php │ ├── Polygon.php │ ├── Position.php │ ├── functions.php │ └── functions_include.php └── tests/ ├── BoundingBoxTest.php ├── DistanceTest.php ├── FunctionsTest.php ├── PolygonTest.php ├── PositionTest.php └── TestCase.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: pull_request: jobs: tests: name: Tests (PHP ${{ matrix.php }}) runs-on: ubuntu-latest strategy: matrix: include: - php: '7.4' - php: '8.0' code-coverage: 'yes' steps: - name: Checkout code uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: pcov - name: Install dependencies run: composer update --no-interaction --no-progress --prefer-dist - name: Run tests if: matrix.code-coverage != 'yes' run: vendor/bin/phpunit --coverage-text - name: Run tests with code coverage if: matrix.code-coverage == 'yes' run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml - name: Upload coverage results to Coveralls if: matrix.code-coverage == 'yes' env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | composer global require php-coveralls/php-coveralls php-coveralls -v ================================================ FILE: .github/workflows/static.yml ================================================ name: Static analysis on: push: branches: - master pull_request: jobs: php-cs-fixer: name: PHP-CS-Fixer runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '7.4' coverage: none extensions: mbstring tools: "cs2pr" - name: Install dependencies run: composer update --no-interaction --no-progress --prefer-dist - name: Run PHP CS Fixer run: vendor/bin/php-cs-fixer fix --dry-run --format=checkstyle | cs2pr psalm: name: Psalm runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '7.4' coverage: none extensions: mbstring, intl - name: Install dependencies run: composer update --no-interaction --no-progress --prefer-dist - name: Run Psalm run: vendor/bin/psalm --no-progress --output-format=github ================================================ FILE: .gitignore ================================================ build/ vendor/ .php-cs-fixer.php .php-cs-fixer.cache .phpunit.result.cache composer.lock composer.phar phpunit.xml ================================================ FILE: .php-cs-fixer.dist.php ================================================ in(__DIR__); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, 'array_syntax' => [ 'syntax' => 'short', ], 'concat_space' => [ 'spacing' => 'one', ], 'global_namespace_import' => [ 'import_classes' => true, 'import_constants' => null, 'import_functions' => true, ], 'native_constant_invocation' => [ 'fix_built_in' => false, ], 'ordered_imports' => [ 'imports_order' => [ 'class', 'function', 'const', ], ], 'single_line_throw' => false, ]) ->setFinder($finder); ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2011-2022 Jan Sorgalla Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Geokit ====== Geokit is a PHP toolkit to solve geo-related tasks like: * Distance calculations. * Heading, midpoint and endpoint calculations. * Rectangular bounding box calculations. [![Build Status](https://github.com/jsor/geokit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jsor/geokit/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/jsor/geokit/badge.svg?branch=main&service=github)](https://coveralls.io/github/jsor/geokit?branch=main) * [Installation](#installation) * [Reference](#reference) * [Distance](#distance) * [Position](#position) * [BoundingBox](#boundingbox) * [Polygon](#polygon) * [Functions](#functions) * [Distance calculations](#distance-calculations) * [Transformations](#transformations) * [Other calculations](#other-calculations) * [License](#license) Installation ------------ Install the latest version with [Composer](https://getcomposer.org). ```bash composer require geokit/geokit ``` Check the [Packagist page](https://packagist.org/packages/geokit/geokit) for all available versions. Reference --------- ### Distance A Distance instance allows for a convenient representation of a distance unit of measure. ```php use Geokit\Distance; $distance = new Distance(1000); // Defaults to meters // or $distance = new Distance(1, Distance::UNIT_KILOMETERS); $meters = $distance->meters(); $kilometers = $distance->kilometers(); $miles = $distance->miles(); $yards = $distance->yards(); $feet = $distance->feet(); $inches = $distance->inches(); $nauticalMiles = $distance->nautical(); ``` A Distance can also be created from a string with an optional unit. ```php use Geokit\Distance; $distance = Distance::fromString('1000'); // Defaults to meters $distance = Distance::fromString('1000m'); $distance = Distance::fromString('1km'); $distance = Distance::fromString('100 miles'); $distance = Distance::fromString('100 yards'); $distance = Distance::fromString('1 foot'); $distance = Distance::fromString('1 inch'); $distance = Distance::fromString('234nm'); ``` ### Position A `Position` is a fundamental construct representing a geographical position in `x` (or `longitude`) and `y` (or `latitude`) coordinates. Note, that `x`/`y` coordinates are kept as is, while `longitude`/`latitude` are normalized. * Longitudes range between -180 and 180 degrees, inclusive. Longitudes above 180 or below -180 are normalized. For example, 480, 840 and 1200 will all be normalized to 120 degrees. * Latitudes range between -90 and 90 degrees, inclusive. Latitudes above 90 or below -90 are normalized. For example, 100 will be normalized to 80 degrees. ```php use Geokit\Position; $position = new Position(181, 91); $x = $position->x(); // Returns 181.0 $y = $position->y(); // Returns 91.0 $longitude = $position->longitude(); // Returns -179.0, normalized $latitude = $position->latitude(); // Returns 89.0, normalized ``` ### BoundingBox A BoundingBox instance represents a rectangle in geographical coordinates, including one that crosses the 180 degrees longitudinal meridian. It is constructed from its left-bottom (south-west) and right-top (north-east) corner points. ```php use Geokit\BoundingBox; use Geokit\Position; $southWest = Position::fromXY(2, 1); $northEast = Position::fromXY(2, 1); $boundingBox = BoundingBox::fromCornerPositions($southWest, $northEast); $southWestPosition = $boundingBox->southWest(); $northEastPosition = $boundingBox->northEast(); $center = $boundingBox->center(); $span = $boundingBox->span(); $boolean = $boundingBox->contains($position); $newBoundingBox = $boundingBox->extend($position); $newBoundingBox = $boundingBox->union($otherBoundingBox); ``` With the `expand()` and `shrink()` methods, you can expand or shrink a BoundingBox instance by a distance. ```php use Geokit\Distance; $expandedBoundingBox = $boundingBox->expand( Distance::fromString('10km') ); $shrinkedBoundingBox = $boundingBox->shrink( Distance::fromString('10km') ); ``` The `toPolygon()` method converts the BoundingBox to an equivalent Polygon instance. ```php $polygon = $boundingBox->toPolygon(); ``` ### Polygon A Polygon instance represents a two-dimensional shape of connected line segments and may either be closed (the first and last point are the same) or open. ```php use Geokit\BoundingBox; use Geokit\Polygon; use Geokit\Position; $polygon = Polygon::fromPositions( Position::fromXY(0, 0), Position::fromXY(1, 0), Position::fromXY(1, 1) ); $closedPolygon = $polygon->close(); /** @var Position $position */ foreach ($polygon as $position) { } $polygon->contains(Position::fromXY(0.5, 0.5)); // true /** @var BoundingBox $boundingBox */ $boundingBox = $polygon->toBoundingBox(); ``` ### Functions Geokit provides several functions to perform geographic calculations. #### Distance calculations * `distanceHaversine(Position $from, Position $to)`: Calculates the approximate sea level great circle (Earth) distance between two points using the Haversine formula. * `distanceVincenty(Position $from, Position $to)`: Calculates the geodetic distance between two points using the Vincenty inverse formula for ellipsoids. ```php use function Geokit\distanceHaversine; use function Geokit\distanceVincenty; $distance1 = distanceHaversine($from, $to); $distance2 = distanceVincenty($from, $to); ``` Both functions return a [Distance](#distance) instance. #### Transformations The `circle()` function calculates a closed circle Polygon given a center, radius and steps for precision. ```php use Geokit\Distance; use Geokit\Position; use function Geokit\circle; $circlePolygon = circle( Position::fromXY(8.50207515, 49.50042565), Distance::fromString('5km'), 32 ); ``` #### Other calculations Other useful functions are: * `heading(Position $from, Position $to)`: Calculates the (initial) heading from the first point to the second point in degrees. * `midpoint(Position $from, Position $to)`: Calculates an intermediate point on the geodesic between the two given points. * `endpoint(Position $start, float $heading, Geokit\Distance $distance)`: Calculates the destination point along a geodesic, given an initial heading and distance, from the given start point. License ------- Copyright (c) 2011-2022 Jan Sorgalla. Released under the [MIT License](LICENSE). ================================================ FILE: composer.json ================================================ { "name": "geokit/geokit", "description": "Geo-Toolkit for PHP", "keywords": ["geo", "geometry", "geography"], "homepage": "https://github.com/jsor/geokit", "license": "MIT", "authors": [ { "name": "Jan Sorgalla", "email": "jsorgalla@gmail.com", "homepage": "http://sorgalla.com" } ], "require": { "php": "^7.3 || ^8.0", "ext-json": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", "vimeo/psalm": "^4.9", "friendsofphp/php-cs-fixer": "^3.1" }, "autoload": { "psr-4": { "Geokit\\": "src/" }, "files": ["src/functions_include.php"] }, "autoload-dev": { "psr-4": { "Geokit\\": "tests/" } } } ================================================ FILE: phpunit.xml.dist ================================================ ./tests/ ./src/ ./src/functions_include.php ================================================ FILE: psalm.xml ================================================ ================================================ FILE: src/BoundingBox.php ================================================ southWest = $southWest; $this->northEast = $northEast; if ($this->southWest->latitude() > $this->northEast->latitude()) { throw new Exception\LogicException( 'Bounding Box south-west coordinate cannot be north of the north-east coordinate' ); } } public static function fromCornerPositions( Position $southWest, Position $northEast ): self { return new self($southWest, $northEast); } /** * @param iterable $iterable */ public static function fromCoordinates(iterable $iterable): self { $array = []; foreach ($iterable as $coordinate) { $array[] = $coordinate; if (isset($array[3])) { break; } } if (!array_key_exists(0, $array)) { throw MissingCoordinateException::create('west', 0); } if (!array_key_exists(1, $array)) { throw MissingCoordinateException::create('south', 1); } if (!array_key_exists(2, $array)) { throw MissingCoordinateException::create('east', 0); } if (!array_key_exists(3, $array)) { throw MissingCoordinateException::create('north', 1); } return new self( Position::fromXY($array[0], $array[1]), Position::fromXY($array[2], $array[3]) ); } /** * @return iterable */ public function toCoordinates(): iterable { return [ $this->southWest->x(), $this->southWest->y(), $this->northEast->x(), $this->northEast->y(), ]; } /** * @return array */ public function jsonSerialize(): array { return [ $this->southWest->x(), $this->southWest->y(), $this->northEast->x(), $this->northEast->y(), ]; } public function southWest(): Position { return $this->southWest; } public function northEast(): Position { return $this->northEast; } public function center(): Position { if ($this->crossesAntimeridian()) { $span = $this->lngSpan( $this->southWest->longitude(), $this->northEast->longitude() ); $lng = $this->southWest->longitude() + $span / 2; } else { $lng = ($this->southWest->longitude() + $this->northEast->longitude()) / 2; } return Position::fromXY( $lng, ($this->southWest->latitude() + $this->northEast->latitude()) / 2 ); } public function span(): Position { return Position::fromXY( $this->lngSpan($this->southWest->longitude(), $this->northEast->longitude()), $this->northEast->latitude() - $this->southWest->latitude() ); } public function crossesAntimeridian(): bool { return $this->southWest->longitude() > $this->northEast->longitude(); } public function contains(Position $position): bool { $lat = $position->latitude(); // check latitude if ($this->southWest->latitude() > $lat || $lat > $this->northEast->latitude() ) { return false; } // check longitude return $this->containsLng($position->longitude()); } public function extend(Position $position): self { $newSouth = min($this->southWest->latitude(), $position->latitude()); $newNorth = max($this->northEast->latitude(), $position->latitude()); $newWest = $this->southWest->longitude(); $newEast = $this->northEast->longitude(); if (!$this->containsLng($position->longitude())) { // try extending east and try extending west, and use the one that // has the smaller longitudinal span $extendEastLngSpan = $this->lngSpan($newWest, $position->longitude()); $extendWestLngSpan = $this->lngSpan($position->longitude(), $newEast); if ($extendEastLngSpan <= $extendWestLngSpan) { $newEast = $position->longitude(); } else { $newWest = $position->longitude(); } } return new self(Position::fromXY($newWest, $newSouth), Position::fromXY($newEast, $newNorth)); } public function union(self $bbox): self { $newBbox = $this->extend($bbox->southWest()); return $newBbox->extend($bbox->northEast()); } public function expand(Distance $distance): self { return self::transformBoundingBox($this, $distance->meters()); } public function shrink(Distance $distance): self { return self::transformBoundingBox($this, -$distance->meters()); } public function toPolygon(): Polygon { return Polygon::fromPositions( Position::fromXY($this->southWest->x(), $this->southWest->y()), Position::fromXY($this->northEast->x(), $this->southWest->y()), Position::fromXY($this->northEast->x(), $this->northEast->y()), Position::fromXY($this->southWest->x(), $this->northEast->y()), Position::fromXY($this->southWest->x(), $this->southWest->y()) ); } /** * @see http://janmatuschek.de/LatitudeLongitudeBoundingCoordinates */ private static function transformBoundingBox(self $bbox, float $distanceInMeters): self { $latSW = deg2rad($bbox->southWest()->latitude()); $lngSW = deg2rad($bbox->southWest()->longitude()); $latNE = deg2rad($bbox->northEast()->latitude()); $lngNE = deg2rad($bbox->northEast()->longitude()); $angularDistance = $distanceInMeters / Earth::RADIUS; $minLat = $latSW - $angularDistance; $maxLat = $latNE + $angularDistance; $deltaLonSW = asin(sin($angularDistance) / cos($latSW)); $deltaLonNE = asin(sin($angularDistance) / cos($latNE)); $minLon = $lngSW - $deltaLonSW; $maxLon = $lngNE + $deltaLonNE; $positionSW = Position::fromXY(rad2deg($minLon), rad2deg($minLat)); $positionNE = Position::fromXY(rad2deg($maxLon), rad2deg($maxLat)); // Check if we're shrinking too much if ($positionSW->latitude() > $positionNE->latitude()) { $center = $bbox->center(); return self::fromCornerPositions($center, $center); } return self::fromCornerPositions($positionSW, $positionNE); } private function containsLng(float $lng): bool { if ($this->crossesAntimeridian()) { return $lng <= $this->northEast->longitude() || $lng >= $this->southWest->longitude(); } return $this->southWest->longitude() <= $lng && $lng <= $this->northEast->longitude(); } private function lngSpan(float $west, float $east): float { return $west > $east ? ($east + 360 - $west) : ($east - $west); } } ================================================ FILE: src/Distance.php ================================================ */ private static $units = [ self::UNIT_METERS => 1.0, self::UNIT_KILOMETERS => 1000.0, self::UNIT_MILES => 1609.344, self::UNIT_YARDS => 0.9144, self::UNIT_FEET => 0.3048, self::UNIT_INCHES => 0.0254, self::UNIT_NAUTICAL => 1852.0, ]; /** @var array */ private static $aliases = [ 'meter' => self::UNIT_METERS, 'metre' => self::UNIT_METERS, 'metres' => self::UNIT_METERS, 'm' => self::UNIT_METERS, 'kilometer' => self::UNIT_KILOMETERS, 'kilometre' => self::UNIT_KILOMETERS, 'kilometres' => self::UNIT_KILOMETERS, 'km' => self::UNIT_KILOMETERS, 'mile' => self::UNIT_MILES, 'mi' => self::UNIT_MILES, 'yard' => self::UNIT_YARDS, 'yd' => self::UNIT_YARDS, 'foot' => self::UNIT_FEET, 'ft' => self::UNIT_FEET, 'nm' => self::UNIT_NAUTICAL, 'inch' => self::UNIT_INCHES, 'in' => self::UNIT_INCHES, '″' => self::UNIT_INCHES, 'nauticalmile' => self::UNIT_NAUTICAL, 'nauticalmiles' => self::UNIT_NAUTICAL, ]; /** @var float */ private $value; public function __construct(float $value, string $unit = self::DEFAULT_UNIT) { if (!isset(self::$units[$unit])) { throw new Exception\InvalidArgumentException( sprintf( 'Invalid unit %s.', json_encode($unit) ) ); } if ($value < 0) { throw new Exception\InvalidArgumentException( sprintf( 'The distance must be a positive number, got "%s".', $value ) ); } $this->value = $value * self::$units[$unit]; } public static function fromString(string $input): self { if ((bool) preg_match('/(\-?\d+\.?\d*)\s*((kilo)?met[er]+s?|m|km|miles?|mi|yards?|yd|feet|foot|ft|in(ch(es)?)?|″|nautical(mile)?s?|nm)?$/u', $input, $match)) { $unit = self::DEFAULT_UNIT; if (isset($match[2])) { $unit = $match[2]; if (!isset(self::$units[$unit])) { $unit = self::$aliases[$unit]; } } return new self((float) $match[1], $unit); } throw new Exception\InvalidArgumentException( sprintf( 'Cannot create Distance from string %s.', json_encode($input) ) ); } public function meters(): float { return $this->value / self::$units[self::UNIT_METERS]; } public function m(): float { return $this->meters(); } public function kilometers(): float { return $this->value / self::$units[self::UNIT_KILOMETERS]; } public function km(): float { return $this->kilometers(); } public function miles(): float { return $this->value / self::$units[self::UNIT_MILES]; } public function mi(): float { return $this->miles(); } public function yards(): float { return $this->value / self::$units[self::UNIT_YARDS]; } public function yd(): float { return $this->yards(); } public function feet(): float { return $this->value / self::$units[self::UNIT_FEET]; } public function ft(): float { return $this->feet(); } public function inches(): float { return $this->value / self::$units[self::UNIT_INCHES]; } public function in(): float { return $this->inches(); } public function nautical(): float { return $this->value / self::$units[self::UNIT_NAUTICAL]; } public function nm(): float { return $this->nautical(); } } ================================================ FILE: src/Earth.php ================================================ */ private $positions; private function __construct(Position ...$positions) { $this->positions = $positions; } public static function fromPositions(Position ...$positions): self { return new self(...$positions); } /** * @param iterable> $iterable */ public static function fromCoordinates(iterable $iterable): self { $positions = []; foreach ($iterable as $position) { $positions[] = Position::fromCoordinates($position); } return new self(...$positions); } public function close(): self { if (0 === count($this->positions)) { return new self(); } $positions = $this->positions; $lastPosition = end($positions); $firstPosition = reset($positions); $isClosed = ( $lastPosition->latitude() === $firstPosition->latitude() && $lastPosition->longitude() === $firstPosition->longitude() ); if (!$isClosed) { $positions[] = clone reset($this->positions); } return new self(...$positions); } /** * @see https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html */ public function contains(Position $position): bool { if (0 === count($this->positions)) { return false; } $positions = $this->positions; $x = $position->longitude(); $y = $position->latitude(); $p = end($positions); $x0 = $p->longitude(); $y0 = $p->latitude(); $inside = false; foreach ($positions as $pos) { $x1 = $pos->longitude(); $y1 = $pos->latitude(); if (($y1 > $y) !== ($y0 > $y) && ($x < ($x0 - $x1) * ($y - $y1) / ($y0 - $y1) + $x1) ) { $inside = !$inside; } $x0 = $x1; $y0 = $y1; } return $inside; } public function toBoundingBox(): BoundingBox { if (0 === count($this->positions)) { throw new Exception\LogicException('Cannot create a BoundingBox from empty Polygon.'); } $positions = $this->positions; $start = array_shift($positions); $bbox = BoundingBox::fromCornerPositions($start, $start); foreach ($positions as $position) { $bbox = $bbox->extend($position); } return $bbox; } public function count(): int { return count($this->positions); } public function getIterator(): Generator { yield from $this->positions; } /** * @return iterable> */ public function toCoordinates(): iterable { foreach ($this->positions as $position) { yield $position->toCoordinates(); } } /** * @return array> */ public function jsonSerialize(): array { $coordinates = []; foreach ($this->positions as $position) { $coordinates[] = $position->jsonSerialize(); } return $coordinates; } } ================================================ FILE: src/Position.php ================================================ x = $x; $this->y = $y; } public static function fromXY(float $x, float $y): self { return new self($x, $y); } /** * @param iterable $iterable */ public static function fromCoordinates(iterable $iterable): self { $array = []; foreach ($iterable as $coordinate) { $array[] = $coordinate; if (isset($array[1])) { break; } } if (!array_key_exists(0, $array)) { throw MissingCoordinateException::create('x', 0); } if (!array_key_exists(1, $array)) { throw MissingCoordinateException::create('y', 1); } return new self($array[0], $array[1]); } public function x(): float { return $this->x; } public function y(): float { return $this->y; } public function longitude(): float { return normalizeLongitude($this->x); } public function latitude(): float { return normalizeLatitude($this->y); } /** * @return iterable */ public function toCoordinates(): iterable { return [$this->x, $this->y]; } /** * @return array */ public function jsonSerialize(): array { return [$this->x, $this->y]; } } ================================================ FILE: src/functions.php ================================================ latitude()); $lng1 = deg2rad($from->longitude()); $lat2 = deg2rad($to->latitude()); $lng2 = deg2rad($to->longitude()); $dLat = $lat2 - $lat1; $dLon = $lng2 - $lng1; $a = sin($dLat / 2) * sin($dLat / 2) + cos($lat1) * cos($lat2) * sin($dLon / 2) * sin($dLon / 2); $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); return new Distance(Earth::RADIUS * $c); } /** * Calculates the geodetic distance between two points using the * Vincenty inverse formula for ellipsoids. * * @see http://en.wikipedia.org/wiki/Vincenty%27s_formulae * @see http://www.movable-type.co.uk/scripts/latlong-vincenty.html */ function distanceVincenty(Position $from, Position $to): Distance { $lat1 = $from->latitude(); $lng1 = $from->longitude(); $lat2 = $to->latitude(); $lng2 = $to->longitude(); $a = Earth::SEMI_MAJOR_AXIS; $b = Earth::SEMI_MINOR_AXIS; $f = Earth::FLATTENING; $L = deg2rad($lng2 - $lng1); $U1 = atan((1 - $f) * tan(deg2rad($lat1))); $U2 = atan((1 - $f) * tan(deg2rad($lat2))); $sinU1 = sin($U1); $cosU1 = cos($U1); $sinU2 = sin($U2); $cosU2 = cos($U2); $lambda = $L; $iterLimit = 100; do { $sinLambda = sin($lambda); $cosLambda = cos($lambda); $sinSigma = sqrt(($cosU2 * $sinLambda) * ($cosU2 * $sinLambda) + ($cosU1 * $sinU2 - $sinU1 * $cosU2 * $cosLambda) * ($cosU1 * $sinU2 - $sinU1 * $cosU2 * $cosLambda)); if (0.0 === $sinSigma) { return new Distance(0); // co-incident points } $cosSigma = $sinU1 * $sinU2 + $cosU1 * $cosU2 * $cosLambda; $sigma = atan2($sinSigma, $cosSigma); $sinAlpha = $cosU1 * $cosU2 * $sinLambda / $sinSigma; $cosSqAlpha = (float) 1 - $sinAlpha * $sinAlpha; if (0.0 !== $cosSqAlpha) { $cos2SigmaM = $cosSigma - 2 * $sinU1 * $sinU2 / $cosSqAlpha; } else { $cos2SigmaM = 0.0; // Equatorial line } $C = $f / 16 * $cosSqAlpha * (4 + $f * (4 - 3 * $cosSqAlpha)); $lambdaP = $lambda; $lambda = $L + (1 - $C) * $f * $sinAlpha * ($sigma + $C * $sinSigma * ($cos2SigmaM + $C * $cosSigma * (-1 + 2 * $cos2SigmaM * $cos2SigmaM))); } while (--$iterLimit > 0 && abs($lambda - $lambdaP) > 1e-12); if ($iterLimit <= 0) { throw new Exception\RuntimeException('Vincenty formula failed to converge.'); } $uSq = $cosSqAlpha * ($a * $a - $b * $b) / ($b * $b); $A = 1 + $uSq / 16384 * (4096 + $uSq * (-768 + $uSq * (320 - 175 * $uSq))); $B = $uSq / 1024 * (256 + $uSq * (-128 + $uSq * (74 - 47 * $uSq))); $deltaSigma = $B * $sinSigma * ($cos2SigmaM + $B / 4 * ($cosSigma * (-1 + 2 * $cos2SigmaM * $cos2SigmaM) - $B / 6 * $cos2SigmaM * (-3 + 4 * $sinSigma * $sinSigma) * (-3 + 4 * $cos2SigmaM * $cos2SigmaM))); $s = $b * $A * ($sigma - $deltaSigma); return new Distance($s); } /** * Calculates the (initial) heading from the first point to the second point * in degrees. */ function heading(Position $from, Position $to): float { $lat1 = $from->latitude(); $lng1 = $from->longitude(); $lat2 = $to->latitude(); $lng2 = $to->longitude(); $lat1 = deg2rad($lat1); $lat2 = deg2rad($lat2); $dLon = deg2rad($lng2 - $lng1); $y = sin($dLon) * cos($lat2); $x = cos($lat1) * sin($lat2) - sin($lat1) * cos($lat2) * cos($dLon); $heading = atan2($y, $x); return fmod(rad2deg($heading) + 360, 360); } /** * Calculates an intermediate point on the geodesic between the two given * points. * * @see http://www.movable-type.co.uk/scripts/latlong.html */ function midpoint(Position $from, Position $to): Position { $lat1 = $from->latitude(); $lng1 = $from->longitude(); $lat2 = $to->latitude(); $lng2 = $to->longitude(); $lat1 = deg2rad($lat1); $lat2 = deg2rad($lat2); $dLon = deg2rad($lng2 - $lng1); $Bx = cos($lat2) * cos($dLon); $By = cos($lat2) * sin($dLon); $lat3 = atan2( sin($lat1) + sin($lat2), sqrt((cos($lat1) + $Bx) * (cos($lat1) + $Bx) + $By * $By) ); $lon3 = deg2rad($lng1) + atan2($By, cos($lat1) + $Bx); return Position::fromXY(rad2deg($lon3), rad2deg($lat3)); } /** * Calculates the destination point along a geodesic, given an initial * heading and distance, from the given start point. * * @see http://www.movable-type.co.uk/scripts/latlong.html */ function endpoint(Position $start, float $heading, Distance $distance): Position { $lat = deg2rad($start->latitude()); $lng = deg2rad($start->longitude()); $angularDistance = $distance->meters() / Earth::RADIUS; $heading = deg2rad($heading); $lat2 = asin( sin($lat) * cos($angularDistance) + cos($lat) * sin($angularDistance) * cos($heading) ); $lon2 = $lng + atan2( sin($heading) * sin($angularDistance) * cos($lat), cos($angularDistance) - sin($lat) * sin($lat2) ); return Position::fromXY(rad2deg($lon2), rad2deg($lat2)); } function circle(Position $center, Distance $radius, int $steps): Polygon { $points = []; for ($i = 0; $i <= $steps; ++$i) { $points[] = endpoint( $center, $i * (-360 / $steps), $radius ); } return Polygon::fromPositions(...$points); } /** * Normalize a latitude into the range (-90, 90) (upper and lower bound * included). */ function normalizeLatitude(float $lat): float { $mod = fmod($lat, 360); if ($mod < -90) { $mod = -180 - $mod; } if ($mod > 90) { $mod = 180 - $mod; } return $mod; } /** * Normalize a longitude into the range (-180, 180) (lower bound excluded, * upper bound included). */ function normalizeLongitude(float $lng): float { $mod = fmod($lng, 360); if ($mod <= -180) { $mod += 360; } if ($mod > 180) { $mod -= 360; } return $mod; } ================================================ FILE: src/functions_include.php ================================================ southWest()->latitude()); self::assertEquals($w, $b->southWest()->longitude()); self::assertEquals($n, $b->northEast()->latitude()); self::assertEquals($e, $b->northEast()->longitude()); } public function testConstructorShouldAcceptPositionsAsFirstAndSecondArgument(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(1.1234, 2.5678), Position::fromXY(3.1234, 4.5678)); self::assertEquals(1.1234, $bbox->southWest()->longitude()); self::assertEquals(2.5678, $bbox->southWest()->latitude()); self::assertEquals(3.1234, $bbox->northEast()->longitude()); self::assertEquals(4.5678, $bbox->northEast()->latitude()); } public function testConstructorShouldThrowExceptionForInvalidSouthCoordinate(): void { $this->expectException(Exception\LogicException::class); BoundingBox::fromCornerPositions(Position::fromXY(90, 1), Position::fromXY(90, 0)); } public function testFromCoordinatesWithArray(): void { $bbox = BoundingBox::fromCoordinates([1, 2, 3, 4]); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); } public function testFromCoordinatesWithIterator(): void { $bbox = BoundingBox::fromCoordinates(new ArrayIterator([1, 2, 3, 4])); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); } public function testFromCoordinatesWithGenerator(): void { $bbox = BoundingBox::fromCoordinates((/** @return Generator */ static function (): Generator { yield 1; yield 2; yield 3; yield 4; })()); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(1.0, $bbox->southWest()->longitude()); self::assertSame(2.0, $bbox->southWest()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); self::assertSame(3.0, $bbox->northEast()->longitude()); self::assertSame(4.0, $bbox->northEast()->latitude()); } public function testFromCoordinatesThrowsExceptionForMissingSouthWestXCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); BoundingBox::fromCoordinates([]); } public function testFromCoordinatesThrowsExceptionForMissingSouthWestYCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); BoundingBox::fromCoordinates([1]); } public function testFromCoordinatesThrowsExceptionForMissingNorthEastXCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); BoundingBox::fromCoordinates([1, 2]); } public function testFromCoordinatesThrowsExceptionForMissingNorthEastYCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); BoundingBox::fromCoordinates([1, 2, 3]); } public function testToCoordinates(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(1, 2), Position::fromXY(3, 4)); self::assertSame([1.0, 2.0, 3.0, 4.0], $bbox->toCoordinates()); } public function testJsonSerialize(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(1.1, 2), Position::fromXY(3.3, 4)); self::assertSame('[1.1,2,3.3,4]', json_encode($bbox)); } public function testGetCenterShouldReturnAPositionObject(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(1.1234, 2.5678), Position::fromXY(3.1234, 4.5678)); $center = Position::fromXY(2.1234, 3.5678); self::assertEquals($center, $bbox->center()); $bbox = BoundingBox::fromCornerPositions(Position::fromXY(179, -45), Position::fromXY(-179, 45)); $center = Position::fromXY(180, 0); self::assertEquals($center, $bbox->center()); } public function testGetSpanShouldReturnAPositionObject(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(1.1234, 2.5678), Position::fromXY(3.1234, 4.5678)); $span = Position::fromXY(2, 2); self::assertEquals($span, $bbox->span()); } public function testContains(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(37, -122), Position::fromXY(38, -123)); self::assertTrue($bbox->contains(Position::fromXY(37, -122))); self::assertTrue($bbox->contains(Position::fromXY(38, -123))); self::assertFalse($bbox->contains(Position::fromXY(-12, -70))); } public function testExtendInACircle(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(0, 0), Position::fromXY(0, 0)); $bbox = $bbox->extend(Position::fromXY(1, 0)); $bbox = $bbox->extend(Position::fromXY(0, 1)); $bbox = $bbox->extend(Position::fromXY(-1, 0)); $bbox = $bbox->extend(Position::fromXY(0, -1)); self::assertBoundingBox($bbox, -1, -1, 1, 1); } public function testUnion(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(-122, 37), Position::fromXY(-122, 37)); $bbox = $bbox->union(BoundingBox::fromCornerPositions(Position::fromXY(123, -38), Position::fromXY(-123, 38))); self::assertBoundingBox($bbox, -38, 123, 38, -122); } public function testCrossesAntimeridian(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(179, -45), Position::fromXY(-179, 45)); self::assertTrue($bbox->crossesAntimeridian()); self::assertEquals(90, $bbox->span()->latitude()); self::assertEquals(2, $bbox->span()->longitude()); } public function testCrossesAntimeridianViaExtend(): void { $bbox = BoundingBox::fromCornerPositions(Position::fromXY(179, -45), Position::fromXY(-179, 45)); $bbox = $bbox->extend(Position::fromXY(-180, 90)); self::assertTrue($bbox->crossesAntimeridian()); self::assertEquals(45, $bbox->span()->latitude()); self::assertEquals(2, $bbox->span()->longitude()); } public function testExpand(): void { $bbox = BoundingBox::fromCornerPositions( Position::fromXY(179, -45), Position::fromXY(-179, 45) ); $expandedBbox = $bbox->expand( new Distance(100, Distance::UNIT_KILOMETERS) ); self::assertEquals( -45.89932036372454, $expandedBbox->southWest()->latitude() ); self::assertEquals( 177.72811671076983, $expandedBbox->southWest()->longitude() ); self::assertEquals( 45.89932036372454, $expandedBbox->northEast()->latitude() ); self::assertEquals( -177.72811671076983, $expandedBbox->northEast()->longitude() ); } public function testShrink(): void { $bbox = BoundingBox::fromCornerPositions( Position::fromXY(178.99872959034192, -45.000898315284132), Position::fromXY(-178.99872959034192, 45.000898315284132) ); $shrinkedBbox = $bbox->shrink( new Distance(100, Distance::UNIT_KILOMETERS) ); self::assertEquals( -44.10157795155959, $shrinkedBbox->southWest()->latitude() ); self::assertEquals( -179.7293671753848, $shrinkedBbox->southWest()->longitude() ); self::assertEquals( 44.10157795155959, $shrinkedBbox->northEast()->latitude() ); self::assertEquals( 179.7293671753848, $shrinkedBbox->northEast()->longitude() ); } public function testShrinkTooMuch(): void { $bbox = BoundingBox::fromCornerPositions( Position::fromXY(1, 1), Position::fromXY(1, 1) ); $shrinkedBbox = $bbox->shrink( new Distance(100) ); self::assertEquals( 1, $shrinkedBbox->southWest()->latitude() ); self::assertEquals( 1, $shrinkedBbox->southWest()->longitude() ); self::assertEquals( 1, $shrinkedBbox->northEast()->latitude() ); self::assertEquals( 1, $shrinkedBbox->northEast()->longitude() ); } public function testToPolygon(): void { $bbox = BoundingBox::fromCornerPositions( Position::fromXY(0, 0), Position::fromXY(10, 10) ); $polygon = $bbox->toPolygon(); self::assertCount(5, $polygon); /** @var Position[] $array */ $array = iterator_to_array($polygon); self::assertEquals( 0, $array[0]->latitude() ); self::assertEquals( 0, $array[0]->longitude() ); self::assertEquals( 0, $array[1]->latitude() ); self::assertEquals( 10, $array[1]->longitude() ); self::assertEquals( 10, $array[2]->latitude() ); self::assertEquals( 10, $array[2]->longitude() ); self::assertEquals( 10, $array[3]->latitude() ); self::assertEquals( 0, $array[3]->longitude() ); } } ================================================ FILE: tests/DistanceTest.php ================================================ meters()); } public function testShouldConvertToMetersWithAlias(): void { $distance = new Distance(1000); self::assertSame(1000.0, $distance->m()); } public function testShouldConvertToKilometers(): void { $distance = new Distance(1000); self::assertSame(1.0, $distance->kilometers()); } public function testShouldConvertToKilometersWithAlias(): void { $distance = new Distance(1000); self::assertSame(1.0, $distance->km()); } public function testShouldConvertToMiles(): void { $distance = new Distance(1000); self::assertSame(0.62137119223733395, $distance->miles()); } public function testShouldConvertToMilesWithAlias(): void { $distance = new Distance(1000); self::assertSame(0.62137119223733395, $distance->mi()); } public function testShouldConvertToYards(): void { $distance = new Distance(1000); self::assertSame(1093.6132983377079, $distance->yards()); } public function testShouldConvertToYardsWithAlias(): void { $distance = new Distance(1000); self::assertSame(1093.6132983377079, $distance->yd()); } public function testShouldConvertToFeet(): void { $distance = new Distance(1000); self::assertSame(3280.8398950131232, $distance->feet()); } public function testShouldConvertToFeetWithAlias(): void { $distance = new Distance(1000); self::assertSame(3280.8398950131232, $distance->ft()); } public function testShouldConvertToInches(): void { $distance = new Distance(1000); self::assertSame(39370.078740157485, $distance->inches()); } public function testShouldConvertToInchesWithAlias(): void { $distance = new Distance(1000); self::assertSame(39370.078740157485, $distance->in()); } public function testShouldConvertToNauticalMiles(): void { $distance = new Distance(1000); self::assertSame(0.5399568034557235, $distance->nautical()); } public function testShouldConvertToNauticalWithAlias(): void { $distance = new Distance(1000); self::assertSame(0.5399568034557235, $distance->nm()); } public function testShouldThrowExceptionForInvalidUnit(): void { $this->expectException(Exception\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid unit "foo".'); new Distance(1000, 'foo'); } public function testShouldThrowExceptionForNegativeValue(): void { $this->expectException(Exception\InvalidArgumentException::class); $this->expectExceptionMessage('The distance must be a positive number, got "-1000".'); new Distance(-1000); } /** * @dataProvider fromStringDataProvider */ public function testFromString(float $value, string $unit): void { self::assertEquals(1000, Distance::fromString(sprintf('%.15F%s', $value, $unit))->meters()); self::assertEquals(1000, Distance::fromString(sprintf('%.15F %s', $value, $unit))->meters(), 'With space'); } public function fromStringDataProvider(): Generator { yield [ 1000, '', ]; yield [ 1000, 'm', ]; yield [ 1000, 'meter', ]; yield [ 1000, 'meters', ]; yield [ 1000, 'metre', ]; yield [ 1000, 'metres', ]; yield [ 1, 'km', ]; yield [ 1, 'kilometer', ]; yield [ 1, 'kilometers', ]; yield [ 1, 'kilometre', ]; yield [ 1, 'kilometres', ]; yield [ 0.62137119223733, 'mi', ]; yield [ 0.62137119223733, 'mile', ]; yield [ 0.62137119223733, 'miles', ]; yield [ 1093.6132983377079, 'yd', ]; yield [ 1093.6132983377079, 'yard', ]; yield [ 1093.6132983377079, 'yards', ]; yield [ 3280.83989501312336, 'ft', ]; yield [ 3280.83989501312336, 'foot', ]; yield [ 3280.83989501312336, 'feet', ]; yield [ 39370.078740157485, '″', ]; yield [ 39370.0787401574856, 'in', ]; yield [ 39370.0787401574856, 'inch', ]; yield [ 39370.078740157485, 'inches', ]; yield [ 0.53995680345572, 'nm', ]; yield [ 0.53995680345572, 'nautical', ]; yield [ 0.53995680345572, 'nauticalmile', ]; yield [ 0.53995680345572, 'nauticalmiles', ]; } public function testFromStringThrowsExceptionForInvalidUnit(): void { $this->expectException(Exception\InvalidArgumentException::class); $this->expectExceptionMessage('Cannot create Distance from string "1000foo".'); Distance::fromString('1000foo'); } public function testFromStringThrowsExceptionForNegativeValue(): void { $this->expectException(Exception\InvalidArgumentException::class); $this->expectExceptionMessage('The distance must be a positive number, got "-1000.5".'); Distance::fromString('-1000.5m'); } } ================================================ FILE: tests/FunctionsTest.php ================================================ meters(), 0.0001 ); } public static function distanceHaversineDataProvider(): Generator { yield [ Position::fromXY(60.463472083210945, 44.65105198323727), Position::fromXY(83.73959356918931, -35.21140778437257), 9185291.4233, ]; yield [ Position::fromXY(-100.69272816181183, 85.67559066228569), Position::fromXY(-169.56520546227694, 8.659202512353659), 8873933.2562, ]; yield [ Position::fromXY(86.67973218485713, -61.20406142435968), Position::fromXY(-112.75070607662201, -46.86954100616276), 7871751.1082, ]; yield [ Position::fromXY(114.92620809003711, 19.441748214885592), Position::fromXY(5.652987342327833, 82.39083864726126), 8141668.5354, ]; yield [ Position::fromXY(71.53828611597419, -15.120142288506031), Position::fromXY(176.72984121367335, -28.01164012402296), 10651065.6175, ]; yield [ Position::fromXY(69.48629681020975, -30.777964973822236), Position::fromXY(-13.63121923059225, 8.096220837906003), 9817278.1798, ]; yield [ Position::fromXY(-144.45135304704309, -69.95015325956047), Position::fromXY(-115.67441381514072, 23.054808229207993), 10590559.7265, ]; } /** * @dataProvider distanceVincentyDataProvider */ public function testDistanceVincenty(Position $pos1, Position $pos2, float $distance): void { self::assertEqualsWithDelta( $distance, distanceVincenty($pos1, $pos2)->meters(), 0.0001 ); } public static function distanceVincentyDataProvider(): Generator { yield [ Position::fromXY(60.463472083210945, 44.65105198323727), Position::fromXY(83.73959356918931, -35.21140778437257), 9151350.5841, ]; yield [ Position::fromXY(-100.69272816181183, 85.67559066228569), Position::fromXY(-169.56520546227694, 8.659202512353659), 8872957.8831, ]; yield [ Position::fromXY(86.67973218485713, -61.20406142435968), Position::fromXY(-112.75070607662201, -46.86954100616276), 7896462.2245, ]; yield [ Position::fromXY(114.92620809003711, 19.441748214885592), Position::fromXY(5.652987342327833, 82.39083864726126), 8148846.7071, ]; yield [ Position::fromXY(71.53828611597419, -15.120142288506031), Position::fromXY(176.72984121367335, -28.01164012402296), 10666157.5230, ]; yield [ Position::fromXY(69.48629681020975, -30.777964973822236), Position::fromXY(-13.63121923059225, 8.096220837906003), 9818690.27471, ]; yield [ Position::fromXY(-144.45135304704309, -69.95015325956047), Position::fromXY(-115.67441381514072, 23.054808229207993), 10564410.1591, ]; } public function testDistanceHaversineCoIncidentPositions(): void { self::assertEquals( 0, distanceVincenty(Position::fromXY(90, 90), Position::fromXY(90, 90))->meters() ); } public function testDistanceHaversineShouldNotConvergeForHalfTripAroundEquator(): void { $this->expectException(Exception\RuntimeException::class); $this->expectExceptionMessage('Vincenty formula failed to converge.'); distanceVincenty(Position::fromXY(0, 0), Position::fromXY(180, 0)); } public function testHeading(): void { self::assertEquals(90, heading(Position::fromXY(0, 0), Position::fromXY(1, 0))); self::assertEquals(0, heading(Position::fromXY(0, 0), Position::fromXY(0, 1))); self::assertEquals(270, heading(Position::fromXY(0, 0), Position::fromXY(-1, 0))); self::assertEquals(180, heading(Position::fromXY(0, 0), Position::fromXY(0, -1))); } public function testMidpoint(): void { $midpoint = midpoint( Position::fromXY(-96.958444, 32.918593), Position::fromXY(-96.990159, 32.969527) ); self::assertEquals( 32.94406100147102, $midpoint->latitude() ); self::assertEquals( -96.974296932499726, $midpoint->longitude() ); } public function testEndpoint(): void { $endpoint = endpoint( Position::fromXY(-96.958444, 32.918593), 332, new Distance(6389.09568) ); self::assertEquals( 32.96932167481445, $endpoint->latitude() ); self::assertEquals( -96.99059694331415, $endpoint->longitude() ); } public function testCircle(): void { $center = Position::fromXY(-75.343, 39.984); $distance = Distance::fromString('50km'); $circle = circle( $center, $distance, 32 ); self::assertCount(33, $circle); self::assertTrue($circle->contains($center)); /** @var Position $point */ foreach ($circle as $point) { self::assertEqualsWithDelta( $distance->meters(), distanceHaversine($center, $point)->meters(), 0.001 ); } } /** * @dataProvider normalizeLatDataProvider */ public function testNormalizeLat(float $a, float $b): void { self::assertEquals($b, normalizeLatitude($a)); } public function normalizeLatDataProvider(): Generator { yield [-365, -5]; yield [-185, 5]; yield [-95, -85]; yield [-90, -90]; yield [5, 5]; yield [90, 90]; yield [100, 80]; yield [185, -5]; yield [365, 5]; } /** * @dataProvider normalizeLngDataProvider */ public function testNormalizeLng(float $a, float $b): void { self::assertEquals($b, normalizeLongitude($a)); } public function normalizeLngDataProvider(): Generator { yield [-545, 175]; yield [-365, -5]; yield [-360, 0]; yield [-185, 175]; yield [-180, 180]; yield [5, 5]; yield [180, 180]; yield [215, -145]; yield [360, 0]; yield [395, 35]; yield [540, 180]; } } ================================================ FILE: tests/PolygonTest.php ================================================ latitude()); self::assertEquals(1, $array[1]->latitude()); self::assertEquals(1, $array[2]->latitude()); } public function testFromCoordinatesWithArray(): void { $polygon = Polygon::fromCoordinates([[1, 2], [2, 3]]); self::assertCount(2, $polygon); } public function testFromCoordinatesWithIterator(): void { $polygon = Polygon::fromCoordinates(new ArrayIterator([[1, 2], [2, 3]])); self::assertCount(2, $polygon); } public function testFromCoordinatesWithGenerator(): void { $polygon = Polygon::fromCoordinates((/** @return Generator> */ static function (): Generator { yield [1, 2]; yield [2, 3]; })()); self::assertCount(2, $polygon); } public function testCloseOpenPolygon(): void { $polygon = Polygon::fromPositions( Position::fromXY(0, 0), Position::fromXY(1, 0), Position::fromXY(1, 1), Position::fromXY(1, 0) ); $closedPolygon = $polygon->close(); $array = iterator_to_array($closedPolygon); self::assertEquals(Position::fromXY(0, 0), $array[count($closedPolygon) - 1]); } public function testCloseEmptyPolygon(): void { $polygon = Polygon::fromPositions(); $closedPolygon = $polygon->close(); self::assertCount(0, $closedPolygon); } public function testCloseAlreadyClosedPolygon(): void { $polygon = Polygon::fromPositions( Position::fromXY(0, 0), Position::fromXY(1, 0), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0) ); $closedPolygon = $polygon->close(); $array = iterator_to_array($closedPolygon); self::assertEquals(Position::fromXY(0, 0), $array[count($closedPolygon) - 1]); } /** * @param array $polygonPositions * * @dataProvider containsDataProvider */ public function testContains(array $polygonPositions, Position $position, bool $expected): void { $polygon = Polygon::fromPositions(...$polygonPositions); self::assertEquals($expected, $polygon->contains($position)); } public function containsDataProvider(): Generator { // Closed counterclockwise polygons yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(0.5, 0.5), true, ]; yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(1.5, 0.5), false, ]; yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(-0.5, 0.5), false, ]; yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(0.5, 1.5), false, ]; yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(0.5, -0.5), false, ]; // Closed clockwise polygons yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), Position::fromXY(0, 0), ], Position::fromXY(0.5, 0.5), true, ]; yield [ [ Position::fromXY(1, 1), Position::fromXY(3, 2), Position::fromXY(2, 3), Position::fromXY(1, 1), ], Position::fromXY(1.5, 1.5), true, ]; // Open counterclockwise polygons yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), ], Position::fromXY(0.5, 0.5), true, ]; // Open clockwise polygons yield [ [ Position::fromXY(0, 0), Position::fromXY(0, 1), Position::fromXY(1, 1), Position::fromXY(1, 0), ], Position::fromXY(0.5, 0.5), true, ]; yield [ [ Position::fromXY(1, 1), Position::fromXY(3, 2), Position::fromXY(2, 3), ], Position::fromXY(1.5, 1.5), true, ]; // Empty polygon yield [ [], Position::fromXY(0.5, 0.5), false, ]; } public function testToBoundingBox(): void { $polygon = Polygon::fromPositions( Position::fromXY(0, 0), Position::fromXY(1, 0), Position::fromXY(1, 1), Position::fromXY(1, 0) ); $bbox = $polygon->toBoundingBox(); self::assertEquals(0, $bbox->southWest()->latitude()); self::assertEquals(0, $bbox->southWest()->longitude()); self::assertEquals(1, $bbox->northEast()->latitude()); self::assertEquals(1, $bbox->northEast()->longitude()); } public function testToBoundingBoxThrowsExceptionForEmptyPolygon(): void { $this->expectException(Exception\LogicException::class); $this->expectExceptionMessage('Cannot create a BoundingBox from empty Polygon.'); $polygon = Polygon::fromPositions(); $polygon->toBoundingBox(); } public function testCountable(): void { $polygon = Polygon::fromPositions( Position::fromXY(0, 0) ); self::assertCount(1, $polygon); } public function testIterable(): void { self::assertIsIterable(Polygon::fromPositions()); } public function testToCoordinates(): void { $polygon = Polygon::fromPositions( Position::fromXY(1, 2) ); $coordinates = $polygon->toCoordinates(); foreach ($coordinates as $positionCoordinates) { self::assertSame([1.0, 2.0], $positionCoordinates); } } public function testJsonSerialize(): void { $polygon = Polygon::fromPositions( Position::fromXY(1.1, 2) ); self::assertSame('[[1.1,2]]', json_encode($polygon)); } } ================================================ FILE: tests/PositionTest.php ================================================ x()); self::assertSame(2.0, $position->y()); self::assertSame(1.0, $position->longitude()); self::assertSame(2.0, $position->latitude()); } public function testConstructorWithNormalization(): void { $position = Position::fromXY(181, 91); self::assertSame(181.0, $position->x()); self::assertSame(91.0, $position->y()); self::assertSame(-179.0, $position->longitude()); self::assertSame(89.0, $position->latitude()); } public function testConstructorWithInts(): void { $position = Position::fromXY(1, 2); self::assertSame(1.0, $position->x()); self::assertSame(2.0, $position->y()); self::assertSame(1.0, $position->longitude()); self::assertSame(2.0, $position->latitude()); } public function testFromCoordinatesWithArray(): void { $position = Position::fromCoordinates([1, 2]); self::assertSame(1.0, $position->x()); self::assertSame(2.0, $position->y()); self::assertSame(1.0, $position->longitude()); self::assertSame(2.0, $position->latitude()); } public function testFromCoordinatesWithIterator(): void { $position = Position::fromCoordinates(new ArrayIterator([1, 2])); self::assertSame(1.0, $position->x()); self::assertSame(2.0, $position->y()); self::assertSame(1.0, $position->longitude()); self::assertSame(2.0, $position->latitude()); } public function testFromCoordinatesWithGenerator(): void { $position = Position::fromCoordinates((/** @return Generator */ static function (): Generator { yield 1; yield 2; })()); self::assertSame(1.0, $position->x()); self::assertSame(2.0, $position->y()); self::assertSame(1.0, $position->longitude()); self::assertSame(2.0, $position->latitude()); } public function testFromCoordinatesThrowsExceptionForMissingXCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); Position::fromCoordinates([]); } public function testFromCoordinatesThrowsExceptionForMissingYCoordinate(): void { $this->expectException(Exception\MissingCoordinateException::class); Position::fromCoordinates([1]); } public function testToCoordinates(): void { $position = Position::fromXY(1, 2); self::assertSame([1.0, 2.0], $position->toCoordinates()); } public function testJsonSerialize(): void { $position = Position::fromXY(1.1, 2); self::assertSame('[1.1,2]', json_encode($position)); } } ================================================ FILE: tests/TestCase.php ================================================