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');