1 <?php 
declare(strict_types
=1); 
   4  * This file is part of the Rapsys PackBundle package. 
   6  * (c) Raphaël Gertz <symfony@rapsys.eu> 
   8  * For the full copyright and license information, please view the LICENSE 
   9  * file that was distributed with this source code. 
  12 namespace Rapsys\PackBundle\Util
; 
  14 use Symfony\Component\Filesystem\Exception\IOExceptionInterface
; 
  15 use Symfony\Component\Filesystem\Filesystem
; 
  18  * Helps manage osm images 
  44          * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers 
  46         protected $servers = [ 
  47                 'osm' => 'https://tile.openstreetmap.org/{Z}/{X}/{Y}.png', 
  48                 'cycle' => 'http://a.tile.thunderforest.com/cycle/{Z}/{X}/{Y}.png', 
  49                 'transport' => 'http://a.tile.thunderforest.com/transport/{Z}/{X}/{Y}.png' 
  58          * Creates a new osm util 
  60          * @param string $cache The cache directory 
  61          * @param string $path The public path 
  62          * @param string $url The public url 
  63          * @param string $server The server key 
  65         function __construct(string $cache, string $path, string $url, string $server = 'osm') { 
  67                 $this->cache 
= $cache.'/'.$server; 
  70                 $this->path 
= $path.'/'.$server; 
  73                 $this->url 
= $url.'/'.$server; 
  76                 $this->server 
= $server; 
  80          * Return the simple image 
  82          * Generate simple image in jpeg format or load it from cache 
  84          * @param string $pathInfo The path info 
  85          * @param string $caption The image caption 
  86          * @param int $updated The updated timestamp 
  87          * @param float $latitude The latitude 
  88          * @param float $longitude The longitude 
  89          * @param int $zoom The zoom 
  90          * @param int $width The width 
  91          * @param int $height The height 
  92          * @return array The image array 
  94         #TODO: rename to getSimple ??? 
  95         public function getImage(string $pathInfo, string $caption, int $updated, float $latitude, float $longitude, int $zoom = 18, int $width = 1280, int $height = 1280): array { 
  97                 $path = $this->path
.$pathInfo.'.jpeg'; 
 100                 $min = $this->path
.$pathInfo.'.min.jpeg'; 
 102                 //Without existing path 
 103                 if (!is_dir($dir = dirname($path))) { 
 104                         //Create filesystem object 
 105                         $filesystem = new Filesystem(); 
 109                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 110                                 $filesystem->mkdir($dir, 0775); 
 111                         } catch (IOExceptionInterface 
$e) { 
 113                                 throw new \
Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e); 
 117                 //With path and min up to date file 
 118                 if (is_file($path) && is_file($min) && ($mtime = stat($path)['mtime']) && ($mintime = stat($min)['mtime']) && $mtime >= $updated && $mintime >= $updated) { 
 121                                 'link' => $this->url
.'/'.$mtime.$pathInfo.'.jpeg', 
 122                                 'min' => $this->url
.'/'.$mintime.$pathInfo.'.min.jpeg', 
 123                                 'caption' => $caption, 
 124                                 'height' => $height / 2, 
 125                                 'width' => $width / 2 
 129                 //Create image instance 
 130                 $image = new \
Imagick(); 
 133                 $image->newImage($width, $height, new \
ImagickPixel('transparent'), 'jpeg'); 
 135                 //Create tile instance 
 136                 $tile = new \
Imagick(); 
 139                 $ctx = stream_context_create( 
 142                                         #'header' => ['Referer: https://www.openstreetmap.org/'], 
 143                                         'max_redirects' => 5, 
 144                                         'timeout' => (int)ini_get('default_socket_timeout'), 
 145                                         #'user_agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36', 
 146                                         'user_agent' => (string)ini_get('user_agent')?:'rapsys_air/2.0.0', 
 152                 $tileX = floor($centerX = $this->longitudeToX($longitude, $zoom)); 
 153                 $tileY = floor($centerY = $this->latitudeToY($latitude, $zoom)); 
 156                 $startX = floor(($tileX * self
::tz 
- $width) / self
::tz
); 
 157                 $startY = floor(($tileY * self
::tz 
- $height) / self
::tz
); 
 160                 $endX = ceil(($tileX * self
::tz + 
$width) / self
::tz
); 
 161                 $endY = ceil(($tileY * self
::tz + 
$height) / self
::tz
); 
 163                 for($x = $startX; $x <= $endX; $x++
) { 
 164                         for($y = $startY; $y <= $endY; $y++
) { 
 166                                 $cache = $this->cache
.'/'.$zoom.'/'.$x.'/'.$y.'.png'; 
 169                                 if (!is_dir($dir = dirname($cache))) { 
 170                                         //Create filesystem object 
 171                                         $filesystem = new Filesystem(); 
 175                                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 176                                                 $filesystem->mkdir($dir, 0775); 
 177                                         } catch (IOExceptionInterface 
$e) { 
 179                                                 throw new \
Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e); 
 183                                 //Without cache image 
 184                                 if (!is_file($cache)) { 
 186                                         $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->servers
[$this->server
]); 
 188                                         //Store tile in cache 
 189                                         file_put_contents($cache, file_get_contents($tileUri, false, $ctx)); 
 193                                 $destX = intval(floor(($width / 2) - self
::tz 
* ($centerX - $x))); 
 196                                 $destY = intval(floor(($height / 2) - self
::tz 
* ($centerY - $y))); 
 198                                 //Read tile from cache 
 199                                 $tile->readImage($cache); 
 202                                 $image->compositeImage($tile, \Imagick
::COMPOSITE_OVER
, $destX, $destY); 
 210                 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916 
 211                 $draw = new \
ImagickDraw(); 
 214                 $draw->setTextAntialias(true); 
 216                 //Set stroke antialias 
 217                 $draw->setStrokeAntialias(true); 
 220                 $draw->setFillColor('#c3c3f9'); 
 223                 $draw->setStrokeColor('#3333c3'); 
 226                 $draw->setStrokeWidth(2); 
 229                 $draw->circle($width/2, $height/2 - 5, $width/2 + 
10, $height/2 + 
5); 
 232                 $image->drawImage($draw); 
 234                 //Strip image exif data and properties 
 235                 $image->stripImage(); 
 238                 //XXX: not supported by imagick :'( 
 239                 $image->setImageProperty('exif:GPSLatitude', $this->latitudeToSexagesimal($latitude)); 
 242                 //XXX: not supported by imagick :'( 
 243                 $image->setImageProperty('exif:GPSLongitude', $this->longitudeToSexagesimal($longitude)); 
 246                 //XXX: not supported by imagick :'( 
 247                 $image->setImageProperty('exif:Description', $caption); 
 249                 //Set progressive jpeg 
 250                 $image->setInterlaceScheme(\Imagick
::INTERLACE_PLANE
); 
 253                 if (!$image->writeImage($path)) { 
 255                         throw new \
Exception(sprintf('Unable to write image "%s"', $path)); 
 258                 //Crop using aspect ratio 
 259                 $image->cropThumbnailImage($width / 2, $height / 2); 
 261                 //Set compression quality 
 262                 $image->setImageCompressionQuality(70); 
 265                 if (!$image->writeImage($min)) { 
 267                         throw new \
Exception(sprintf('Unable to write image "%s"', $min)); 
 272                         'link' => $this->url
.'/'.stat($path)['mtime'].$pathInfo.'.jpeg', 
 273                         'min' => $this->url
.'/'.stat($min)['mtime'].$pathInfo.'.min.jpeg', 
 274                         'caption' => $caption, 
 275                         'height' => $height / 2, 
 276                         'width' => $width / 2 
 281          * Return the multi image 
 283          * Generate multi image in jpeg format or load it from cache 
 285          * @param string $pathInfo The path info 
 286          * @param string $caption The image caption 
 287          * @param int $updated The updated timestamp 
 288          * @param float $latitude The latitude 
 289          * @param float $longitude The longitude 
 290          * @param array $locations The latitude array 
 291          * @param int $zoom The zoom 
 292          * @param int $width The width 
 293          * @param int $height The height 
 294          * @return array The image array 
 296         public function getMultiImage(string $pathInfo, string $caption, int $updated, float $latitude, float $longitude, array $locations, int $zoom = 18, int $width = 1280, int $height = 1280): array { 
 298                 $path = $this->path
.$pathInfo.'.jpeg'; 
 301                 $min = $this->path
.$pathInfo.'.min.jpeg'; 
 303                 //Without existing path 
 304                 if (!is_dir($dir = dirname($path))) { 
 305                         //Create filesystem object 
 306                         $filesystem = new Filesystem(); 
 310                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 311                                 $filesystem->mkdir($dir, 0775); 
 312                         } catch (IOExceptionInterface 
$e) { 
 314                                 throw new \
Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e); 
 318                 //With path and min up to date file 
 319                 if (is_file($path) && is_file($min) && ($mtime = stat($path)['mtime']) && ($mintime = stat($min)['mtime']) && $mtime >= $updated && $mintime >= $updated) { 
 322                                 'link' => $this->url
.'/'.$mtime.$pathInfo.'.jpeg', 
 323                                 'min' => $this->url
.'/'.$mintime.$pathInfo.'.min.jpeg', 
 324                                 'caption' => $caption, 
 325                                 'height' => $height / 2, 
 326                                 'width' => $width / 2 
 330                 //Create image instance 
 331                 $image = new \
Imagick(); 
 334                 $image->newImage($width, $height, new \
ImagickPixel('transparent'), 'jpeg'); 
 336                 //Create tile instance 
 337                 $tile = new \
Imagick(); 
 340                 $ctx = stream_context_create( 
 343                                         #'header' => ['Referer: https://www.openstreetmap.org/'], 
 344                                         'max_redirects' => 5, 
 345                                         'timeout' => (int)ini_get('default_socket_timeout'), 
 346                                         #'user_agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36', 
 347                                         'user_agent' => (string)ini_get('user_agent')?:'rapsys_air/2.0.0', 
 353                 $tileX = floor($centerX = $this->longitudeToX($longitude, $zoom)); 
 354                 $tileY = floor($centerY = $this->latitudeToY($latitude, $zoom)); 
 357                 //XXX: we draw every tile starting beween -($width / 2) and 0 
 358                 $startX = floor(($tileX * self
::tz 
- $width) / self
::tz
); 
 359                 //XXX: we draw every tile starting beween -($height / 2) and 0 
 360                 $startY = floor(($tileY * self
::tz 
- $height) / self
::tz
); 
 363                 //TODO: this seems stupid, check if we may just divide $width / 2 here !!! 
 364                 //XXX: we draw every tile starting beween $width + ($width / 2) 
 365                 $endX = ceil(($tileX * self
::tz + 
$width) / self
::tz
); 
 366                 //XXX: we draw every tile starting beween $width + ($width / 2) 
 367                 $endY = ceil(($tileY * self
::tz + 
$height) / self
::tz
); 
 369                 for($x = $startX; $x <= $endX; $x++
) { 
 370                         for($y = $startY; $y <= $endY; $y++
) { 
 372                                 $cache = $this->cache
.'/'.$zoom.'/'.$x.'/'.$y.'.png'; 
 375                                 if (!is_dir($dir = dirname($cache))) { 
 376                                         //Create filesystem object 
 377                                         $filesystem = new Filesystem(); 
 381                                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 382                                                 $filesystem->mkdir($dir, 0775); 
 383                                         } catch (IOExceptionInterface 
$e) { 
 385                                                 throw new \
Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e); 
 389                                 //Without cache image 
 390                                 if (!is_file($cache)) { 
 392                                         $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->servers
[$this->server
]); 
 394                                         //Store tile in cache 
 395                                         file_put_contents($cache, file_get_contents($tileUri, false, $ctx)); 
 399                                 $destX = intval(floor(($width / 2) - self
::tz 
* ($centerX - $x))); 
 402                                 $destY = intval(floor(($height / 2) - self
::tz 
* ($centerY - $y))); 
 404                                 //Read tile from cache 
 405                                 $tile->readImage($cache); 
 408                                 $image->compositeImage($tile, \Imagick
::COMPOSITE_OVER
, $destX, $destY); 
 416                 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916 
 417                 $draw = new \
ImagickDraw(); 
 420                 $draw->setTextAlignment(\Imagick
::ALIGN_CENTER
); 
 423                 $draw->setTextAntialias(true); 
 425                 //Set stroke antialias 
 426                 $draw->setStrokeAntialias(true); 
 428                 //Iterate on locations 
 429                 foreach($locations as $k => $location) { 
 431                         $destX = intval(floor(($width / 2) - self
::tz 
* ($centerX - $this->longitudeToX($location['longitude'], $zoom)))); 
 434                         $destY = intval(floor(($height / 2) - self
::tz 
* ($centerY - $this->latitudeToY($location['latitude'], $zoom)))); 
 437                         $draw->setFillColor('#cff'); 
 440                         $draw->setFontSize(20); 
 443                         $draw->setStrokeColor('#00c3f9'); 
 451                         //With matching position 
 452                         if ($location['latitude'] === $latitude && $location['longitude'] == $longitude) { 
 454                                 $draw->setFillColor('#c3c3f9'); 
 457                                 $draw->setFontSize(30); 
 460                                 $draw->setStrokeColor('#3333c3'); 
 470                         $draw->setStrokeWidth($stroke); 
 473                         $draw->circle($destX, $destY - $radius, $destX + 
$radius * 2, $destY + 
$radius); 
 476                         $draw->setFillColor($draw->getStrokeColor()); 
 479                         $draw->setStrokeWidth($stroke / 4); 
 482                         $metrics = $image->queryFontMetrics($draw, strval($location['id'])); 
 485                         $draw->annotation($destX, $destY - $metrics['descender'] / 3, strval($location['id'])); 
 489                 $image->drawImage($draw); 
 491                 //Strip image exif data and properties 
 492                 $image->stripImage(); 
 495                 //XXX: not supported by imagick :'( 
 496                 $image->setImageProperty('exif:GPSLatitude', $this->latitudeToSexagesimal($latitude)); 
 499                 //XXX: not supported by imagick :'( 
 500                 $image->setImageProperty('exif:GPSLongitude', $this->longitudeToSexagesimal($longitude)); 
 503                 //XXX: not supported by imagick :'( 
 504                 $image->setImageProperty('exif:Description', $caption); 
 506                 //Set progressive jpeg 
 507                 $image->setInterlaceScheme(\Imagick
::INTERLACE_PLANE
); 
 510                 if (!$image->writeImage($path)) { 
 512                         throw new \
Exception(sprintf('Unable to write image "%s"', $path)); 
 515                 //Crop using aspect ratio 
 516                 $image->cropThumbnailImage($width / 2, $height / 2); 
 518                 //Set compression quality 
 519                 $image->setImageCompressionQuality(70); 
 522                 if (!$image->writeImage($min)) { 
 524                         throw new \
Exception(sprintf('Unable to write image "%s"', $min)); 
 529                         'link' => $this->url
.'/'.stat($path)['mtime'].$pathInfo.'.jpeg', 
 530                         'min' => $this->url
.'/'.stat($min)['mtime'].$pathInfo.'.min.jpeg', 
 531                         'caption' => $caption, 
 532                         'height' => $height / 2, 
 533                         'width' => $width / 2 
 540          * Compute multi image optimal zoom 
 542          * @param float $latitude The latitude 
 543          * @param float $longitude The longitude 
 544          * @param array $locations The latitude array 
 545          * @param int $zoom The zoom 
 546          * @param int $width The width 
 547          * @param int $height The height 
 548          * @return int The zoom 
 550         public function getMultiZoom(float $latitude, float $longitude, array $locations, int $zoom = 18, int $width = 1280, int $height = 1280): int { 
 551                 //Iterate on each zoom 
 552                 for ($i = $zoom; $i >= 1; $i--) { 
 554                         $tileX = floor($this->longitudeToX($longitude, $i)); 
 555                         $tileY = floor($this->latitudeToY($latitude, $i)); 
 558                         $startX = floor(($tileX * self
::tz 
- $width / 2) / self
::tz
); 
 559                         $startY = floor(($tileY * self
::tz 
- $height / 2) / self
::tz
); 
 562                         $endX = ceil(($tileX * self
::tz + 
$width / 2) / self
::tz
); 
 563                         $endY = ceil(($tileY * self
::tz + 
$height / 2) / self
::tz
); 
 565                         //Iterate on each locations 
 566                         foreach($locations as $k => $location) { 
 568                                 $destX = floor($this->longitudeToX($location['longitude'], $i)); 
 571                                 if ($startX >= $destX || $endX <= $destX) { 
 577                                 $destY = floor($this->latitudeToY($location['latitude'], $i)); 
 580                                 if ($startY >= $destY || $endY <= $destY) { 
 595          * Convert longitude to tile x number 
 597          * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5 
 599          * @param float $longitude The longitude 
 600          * @param int $zoom The zoom 
 602          * @return float The tile x 
 604         public function longitudeToX(float $longitude, int $zoom): float { 
 605                 return (($longitude + 
180) / 360) * pow(2, $zoom); 
 609          * Convert latitude to tile y number 
 611          * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5 
 613          * @param $latitude The latitude 
 614          * @param $zoom The zoom 
 616          * @return float The tile y 
 618         public function latitudeToY(float $latitude, int $zoom): float { 
 619                 return (1 - log(tan(deg2rad($latitude)) + 
1 / cos(deg2rad($latitude))) / pi()) / 2 * pow(2, $zoom); 
 623          * Convert tile x to longitude 
 625          * @param float $x The tile x 
 626          * @param int $zoom The zoom 
 628          * @return float The longitude 
 630         public function xToLongitude(float $x, int $zoom): float { 
 631                 return $x / pow(2, $zoom) * 360.0 - 180.0; 
 635          * Convert tile y to latitude 
 637          * @param float $y The tile y 
 638          * @param int $zoom The zoom 
 640          * @return float The latitude 
 642         public function yToLatitude(float $y, int $zoom): float { 
 643                 return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom))))); 
 647          * Convert decimal latitude to sexagesimal 
 649          * @param float $latitude The decimal latitude 
 651          * @return string The sexagesimal longitude 
 653         public function latitudeToSexagesimal(float $latitude): string { 
 655                 $degree = $latitude % 
60; 
 658                 $minute = ($latitude - $degree) * 60 % 
60; 
 661                 $second = ($latitude - $degree - $minute / 60) * 3600 % 
3600; 
 663                 //Return sexagesimal longitude 
 664                 return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S'); 
 668          * Convert decimal longitude to sexagesimal 
 670          * @param float $longitude The decimal longitude 
 672          * @return string The sexagesimal longitude 
 674         public function longitudeToSexagesimal(float $longitude): string { 
 676                 $degree = $longitude % 
60; 
 679                 $minute = ($longitude - $degree) * 60 % 
60; 
 682                 $second = ($longitude - $degree - $minute / 60) * 3600 % 
3600; 
 684                 //Return sexagesimal longitude 
 685                 return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');