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.
[](https://github.com/jsor/geokit/actions/workflows/ci.yml)
[](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
================================================