]> Raphaël G. Git Repositories - packbundle/blob - Util/OsmUtil.php
Add getMultiImage
[packbundle] / Util / OsmUtil.php
1 <?php declare(strict_types=1);
2
3 /*
4 * This file is part of the Rapsys PackBundle package.
5 *
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Rapsys\PackBundle\Util;
13
14 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
15 use Symfony\Component\Filesystem\Filesystem;
16
17 /**
18 * Helps manage osm images
19 */
20 class OsmUtil {
21 /**
22 * The tile size
23 */
24 const tz = 256;
25
26 /**
27 * The cache directory
28 */
29 protected $cache;
30
31 /**
32 * The public path
33 */
34 protected $path;
35
36 /**
37 * The tile server
38 */
39 protected $server;
40
41 /**
42 * The tile servers
43 *
44 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
45 */
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'
50 ];
51
52 /**
53 * The public url
54 */
55 protected $url;
56
57 /**
58 * Creates a new osm util
59 *
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
64 */
65 function __construct(string $cache, string $path, string $url, string $server = 'osm') {
66 //Set cache
67 $this->cache = $cache.'/'.$server;
68
69 //Set path
70 $this->path = $path.'/'.$server;
71
72 //Set url
73 $this->url = $url.'/'.$server;
74
75 //Set server key
76 $this->server = $server;
77 }
78
79 /**
80 * Return the simple image
81 *
82 * Generate simple image in jpeg format or load it from cache
83 *
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
93 */
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 {
96 //Set path file
97 $path = $this->path.$pathInfo.'.jpeg';
98
99 //Set min file
100 $min = $this->path.$pathInfo.'.min.jpeg';
101
102 //Without existing path
103 if (!is_dir($dir = dirname($path))) {
104 //Create filesystem object
105 $filesystem = new Filesystem();
106
107 try {
108 //Create path
109 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
110 $filesystem->mkdir($dir, 0775);
111 } catch (IOExceptionInterface $e) {
112 //Throw error
113 throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
114 }
115 }
116
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) {
119 //Return image data
120 return [
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
126 ];
127 }
128
129 //Create image instance
130 $image = new \Imagick();
131
132 //Add new image
133 $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
134
135 //Create tile instance
136 $tile = new \Imagick();
137
138 //Init context
139 $ctx = stream_context_create(
140 [
141 'http' => [
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',
147 ]
148 ]
149 );
150
151 //Get tile xy
152 $tileX = floor($centerX = $this->longitudeToX($longitude, $zoom));
153 $tileY = floor($centerY = $this->latitudeToY($latitude, $zoom));
154
155 //Calculate start xy
156 $startX = floor(($tileX * self::tz - $width) / self::tz);
157 $startY = floor(($tileY * self::tz - $height) / self::tz);
158
159 //Calculate end xy
160 $endX = ceil(($tileX * self::tz + $width) / self::tz);
161 $endY = ceil(($tileY * self::tz + $height) / self::tz);
162
163 for($x = $startX; $x <= $endX; $x++) {
164 for($y = $startY; $y <= $endY; $y++) {
165 //Set cache path
166 $cache = $this->cache.'/'.$zoom.'/'.$x.'/'.$y.'.png';
167
168 //Without cache path
169 if (!is_dir($dir = dirname($cache))) {
170 //Create filesystem object
171 $filesystem = new Filesystem();
172
173 try {
174 //Create path
175 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
176 $filesystem->mkdir($dir, 0775);
177 } catch (IOExceptionInterface $e) {
178 //Throw error
179 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
180 }
181 }
182
183 //Without cache image
184 if (!is_file($cache)) {
185 //Set tile url
186 $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->servers[$this->server]);
187
188 //Store tile in cache
189 file_put_contents($cache, file_get_contents($tileUri, false, $ctx));
190 }
191
192 //Set dest x
193 $destX = intval(floor(($width / 2) - self::tz * ($centerX - $x)));
194
195 //Set dest y
196 $destY = intval(floor(($height / 2) - self::tz * ($centerY - $y)));
197
198 //Read tile from cache
199 $tile->readImage($cache);
200
201 //Compose image
202 $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
203
204 //Clear tile
205 $tile->clear();
206 }
207 }
208
209 //Add circle
210 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
211 $draw = new \ImagickDraw();
212
213 //Set text antialias
214 $draw->setTextAntialias(true);
215
216 //Set stroke antialias
217 $draw->setStrokeAntialias(true);
218
219 //Set fill color
220 $draw->setFillColor('#c3c3f9');
221
222 //Set stroke color
223 $draw->setStrokeColor('#3333c3');
224
225 //Set stroke width
226 $draw->setStrokeWidth(2);
227
228 //Draw circle
229 $draw->circle($width/2, $height/2 - 5, $width/2 + 10, $height/2 + 5);
230
231 //Draw on image
232 $image->drawImage($draw);
233
234 //Strip image exif data and properties
235 $image->stripImage();
236
237 //Add latitude
238 //XXX: not supported by imagick :'(
239 $image->setImageProperty('exif:GPSLatitude', $this->latitudeToSexagesimal($latitude));
240
241 //Add longitude
242 //XXX: not supported by imagick :'(
243 $image->setImageProperty('exif:GPSLongitude', $this->longitudeToSexagesimal($longitude));
244
245 //Add description
246 //XXX: not supported by imagick :'(
247 $image->setImageProperty('exif:Description', $caption);
248
249 //Set progressive jpeg
250 $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
251
252 //Save image
253 if (!$image->writeImage($path)) {
254 //Throw error
255 throw new \Exception(sprintf('Unable to write image "%s"', $path));
256 }
257
258 //Crop using aspect ratio
259 $image->cropThumbnailImage($width / 2, $height / 2);
260
261 //Set compression quality
262 $image->setImageCompressionQuality(70);
263
264 //Save min image
265 if (!$image->writeImage($min)) {
266 //Throw error
267 throw new \Exception(sprintf('Unable to write image "%s"', $min));
268 }
269
270 //Return image data
271 return [
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
277 ];
278 }
279
280 /**
281 * Return the multi image
282 *
283 * Generate multi image in jpeg format or load it from cache
284 *
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
295 */
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 {
297 //Set path file
298 $path = $this->path.$pathInfo.'.jpeg';
299
300 //Set min file
301 $min = $this->path.$pathInfo.'.min.jpeg';
302
303 //Without existing path
304 if (!is_dir($dir = dirname($path))) {
305 //Create filesystem object
306 $filesystem = new Filesystem();
307
308 try {
309 //Create path
310 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
311 $filesystem->mkdir($dir, 0775);
312 } catch (IOExceptionInterface $e) {
313 //Throw error
314 throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
315 }
316 }
317
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) {
320 //Return image data
321 return [
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
327 ];
328 }
329
330 //Create image instance
331 $image = new \Imagick();
332
333 //Add new image
334 $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
335
336 //Create tile instance
337 $tile = new \Imagick();
338
339 //Init context
340 $ctx = stream_context_create(
341 [
342 'http' => [
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',
348 ]
349 ]
350 );
351
352 //Get tile xy
353 $tileX = floor($centerX = $this->longitudeToX($longitude, $zoom));
354 $tileY = floor($centerY = $this->latitudeToY($latitude, $zoom));
355
356 //Calculate start xy
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);
361
362 //Calculate end xy
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);
368
369 for($x = $startX; $x <= $endX; $x++) {
370 for($y = $startY; $y <= $endY; $y++) {
371 //Set cache path
372 $cache = $this->cache.'/'.$zoom.'/'.$x.'/'.$y.'.png';
373
374 //Without cache path
375 if (!is_dir($dir = dirname($cache))) {
376 //Create filesystem object
377 $filesystem = new Filesystem();
378
379 try {
380 //Create path
381 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
382 $filesystem->mkdir($dir, 0775);
383 } catch (IOExceptionInterface $e) {
384 //Throw error
385 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
386 }
387 }
388
389 //Without cache image
390 if (!is_file($cache)) {
391 //Set tile url
392 $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->servers[$this->server]);
393
394 //Store tile in cache
395 file_put_contents($cache, file_get_contents($tileUri, false, $ctx));
396 }
397
398 //Set dest x
399 $destX = intval(floor(($width / 2) - self::tz * ($centerX - $x)));
400
401 //Set dest y
402 $destY = intval(floor(($height / 2) - self::tz * ($centerY - $y)));
403
404 //Read tile from cache
405 $tile->readImage($cache);
406
407 //Compose image
408 $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
409
410 //Clear tile
411 $tile->clear();
412 }
413 }
414
415 //Add circle
416 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
417 $draw = new \ImagickDraw();
418
419 //Set text alignment
420 $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
421
422 //Set text antialias
423 $draw->setTextAntialias(true);
424
425 //Set stroke antialias
426 $draw->setStrokeAntialias(true);
427
428 //Iterate on locations
429 foreach($locations as $k => $location) {
430 //Set dest x
431 $destX = intval(floor(($width / 2) - self::tz * ($centerX - $this->longitudeToX($location['longitude'], $zoom))));
432
433 //Set dest y
434 $destY = intval(floor(($height / 2) - self::tz * ($centerY - $this->latitudeToY($location['latitude'], $zoom))));
435
436 //Set fill color
437 $draw->setFillColor('#cff');
438
439 //Set font size
440 $draw->setFontSize(20);
441
442 //Set stroke color
443 $draw->setStrokeColor('#00c3f9');
444
445 //Set circle radius
446 $radius = 5;
447
448 //Set stroke
449 $stroke = 2;
450
451 //With matching position
452 if ($location['latitude'] === $latitude && $location['longitude'] == $longitude) {
453 //Set fill color
454 $draw->setFillColor('#c3c3f9');
455
456 //Set font size
457 $draw->setFontSize(30);
458
459 //Set stroke color
460 $draw->setStrokeColor('#3333c3');
461
462 //Set circle radius
463 $radius = 8;
464
465 //Set stroke
466 $stroke = 4;
467 }
468
469 //Set stroke width
470 $draw->setStrokeWidth($stroke);
471
472 //Draw circle
473 $draw->circle($destX, $destY - $radius, $destX + $radius * 2, $destY + $radius);
474
475 //Set fill color
476 $draw->setFillColor($draw->getStrokeColor());
477
478 //Set stroke width
479 $draw->setStrokeWidth($stroke / 4);
480
481 //Get font metrics
482 $metrics = $image->queryFontMetrics($draw, strval($location['id']));
483
484 //Add annotation
485 $draw->annotation($destX, $destY - $metrics['descender'] / 3, strval($location['id']));
486 }
487
488 //Draw on image
489 $image->drawImage($draw);
490
491 //Strip image exif data and properties
492 $image->stripImage();
493
494 //Add latitude
495 //XXX: not supported by imagick :'(
496 $image->setImageProperty('exif:GPSLatitude', $this->latitudeToSexagesimal($latitude));
497
498 //Add longitude
499 //XXX: not supported by imagick :'(
500 $image->setImageProperty('exif:GPSLongitude', $this->longitudeToSexagesimal($longitude));
501
502 //Add description
503 //XXX: not supported by imagick :'(
504 $image->setImageProperty('exif:Description', $caption);
505
506 //Set progressive jpeg
507 $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
508
509 //Save image
510 if (!$image->writeImage($path)) {
511 //Throw error
512 throw new \Exception(sprintf('Unable to write image "%s"', $path));
513 }
514
515 //Crop using aspect ratio
516 $image->cropThumbnailImage($width / 2, $height / 2);
517
518 //Set compression quality
519 $image->setImageCompressionQuality(70);
520
521 //Save min image
522 if (!$image->writeImage($min)) {
523 //Throw error
524 throw new \Exception(sprintf('Unable to write image "%s"', $min));
525 }
526
527 //Return image data
528 return [
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
534 ];
535 }
536
537 /**
538 * Return multi zoom
539 *
540 * Compute multi image optimal zoom
541 *
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
549 */
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--) {
553 //Get tile xy
554 $tileX = floor($this->longitudeToX($longitude, $i));
555 $tileY = floor($this->latitudeToY($latitude, $i));
556
557 //Calculate start xy
558 $startX = floor(($tileX * self::tz - $width / 2) / self::tz);
559 $startY = floor(($tileY * self::tz - $height / 2) / self::tz);
560
561 //Calculate end xy
562 $endX = ceil(($tileX * self::tz + $width / 2) / self::tz);
563 $endY = ceil(($tileY * self::tz + $height / 2) / self::tz);
564
565 //Iterate on each locations
566 foreach($locations as $k => $location) {
567 //Set dest x
568 $destX = floor($this->longitudeToX($location['longitude'], $i));
569
570 //With outside point
571 if ($startX >= $destX || $endX <= $destX) {
572 //Skip zoom
573 continue(2);
574 }
575
576 //Set dest y
577 $destY = floor($this->latitudeToY($location['latitude'], $i));
578
579 //With outside point
580 if ($startY >= $destY || $endY <= $destY) {
581 //Skip zoom
582 continue(2);
583 }
584 }
585
586 //Found zoom
587 break;
588 }
589
590 //Return zoom
591 return $i;
592 }
593
594 /**
595 * Convert longitude to tile x number
596 *
597 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
598 *
599 * @param float $longitude The longitude
600 * @param int $zoom The zoom
601 *
602 * @return float The tile x
603 */
604 public function longitudeToX(float $longitude, int $zoom): float {
605 return (($longitude + 180) / 360) * pow(2, $zoom);
606 }
607
608 /**
609 * Convert latitude to tile y number
610 *
611 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
612 *
613 * @param $latitude The latitude
614 * @param $zoom The zoom
615 *
616 * @return float The tile y
617 */
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);
620 }
621
622 /**
623 * Convert tile x to longitude
624 *
625 * @param float $x The tile x
626 * @param int $zoom The zoom
627 *
628 * @return float The longitude
629 */
630 public function xToLongitude(float $x, int $zoom): float {
631 return $x / pow(2, $zoom) * 360.0 - 180.0;
632 }
633
634 /**
635 * Convert tile y to latitude
636 *
637 * @param float $y The tile y
638 * @param int $zoom The zoom
639 *
640 * @return float The latitude
641 */
642 public function yToLatitude(float $y, int $zoom): float {
643 return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom)))));
644 }
645
646 /**
647 * Convert decimal latitude to sexagesimal
648 *
649 * @param float $latitude The decimal latitude
650 *
651 * @return string The sexagesimal longitude
652 */
653 public function latitudeToSexagesimal(float $latitude): string {
654 //Set degree
655 $degree = $latitude % 60;
656
657 //Set minute
658 $minute = ($latitude - $degree) * 60 % 60;
659
660 //Set second
661 $second = ($latitude - $degree - $minute / 60) * 3600 % 3600;
662
663 //Return sexagesimal longitude
664 return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S');
665 }
666
667 /**
668 * Convert decimal longitude to sexagesimal
669 *
670 * @param float $longitude The decimal longitude
671 *
672 * @return string The sexagesimal longitude
673 */
674 public function longitudeToSexagesimal(float $longitude): string {
675 //Set degree
676 $degree = $longitude % 60;
677
678 //Set minute
679 $minute = ($longitude - $degree) * 60 % 60;
680
681 //Set second
682 $second = ($longitude - $degree - $minute / 60) * 3600 % 3600;
683
684 //Return sexagesimal longitude
685 return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');
686 }
687 }