You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

594 lines
19 KiB

1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
  1. <?php
  2. namespace p3k\geo\StaticMap;
  3. use p3k\geo\WebMercator, p3k\Geocoder;
  4. use Imagick, ImagickPixel, ImagickDraw;
  5. function generate($params, $filename, $assetPath, $is_authenticated=false) {
  6. $bounds = array(
  7. 'minLat' => 90,
  8. 'maxLat' => -90,
  9. 'minLng' => 180,
  10. 'maxLng' => -180
  11. );
  12. // If any markers are specified, choose a default lat/lng as the center of all the markers
  13. $markers = array();
  14. if($markersTemp=k($params,'marker')) {
  15. if(!is_array($markersTemp))
  16. $markersTemp = array($markersTemp);
  17. // If no latitude is set, use the center of all the markers
  18. foreach($markersTemp as $i=>$m) {
  19. if(preg_match_all('/(?P<k>[a-z]+):(?P<v>[^;]+)/', $m, $matches)) {
  20. $properties = array();
  21. foreach($matches['k'] as $j=>$key) {
  22. $properties[$key] = $matches['v'][$j];
  23. }
  24. // Skip invalid marker definitions, show error in a header
  25. if(array_key_exists('icon', $properties) && (
  26. (array_key_exists('lat', $properties) && array_key_exists('lng', $properties))
  27. || array_key_exists('location', $properties)
  28. )
  29. ) {
  30. // Geocode the provided location and return lat/lng
  31. if(array_key_exists('location', $properties)) {
  32. $result = Geocoder::geocode($properties['location']);
  33. if(!$result) {
  34. #header('X-Marker-' . ($i+1) . ': error geocoding location "' . $properties['location'] . '"');
  35. continue;
  36. }
  37. $properties['lat'] = $result->latitude;
  38. $properties['lng'] = $result->longitude;
  39. }
  40. if(preg_match('/https?:\/\/(.+)/', $properties['icon'], $match)) {
  41. $properties['iconImg'] = false;
  42. // Only allow external referenced icons from authenticated requests
  43. if($is_authenticated) {
  44. // Looks like an external image, attempt to download it
  45. $ch = curl_init($properties['icon']);
  46. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  47. curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
  48. $img = curl_exec($ch);
  49. $properties['iconImg'] = @imagecreatefromstring($img);
  50. }
  51. } else {
  52. $properties['iconImg'] = imagecreatefrompng($assetPath . '/' . $properties['icon'] . '.png');
  53. }
  54. if($properties['iconImg']) {
  55. $markers[] = $properties;
  56. }
  57. if($properties['lat'] < $bounds['minLat'])
  58. $bounds['minLat'] = $properties['lat'];
  59. if($properties['lat'] > $bounds['maxLat'])
  60. $bounds['maxLat'] = $properties['lat'];
  61. if($properties['lng'] < $bounds['minLng'])
  62. $bounds['minLng'] = $properties['lng'];
  63. if($properties['lng'] > $bounds['maxLng'])
  64. $bounds['maxLng'] = $properties['lng'];
  65. } else {
  66. #header('X-Marker-' . ($i+1) . ': missing icon, or lat/lng/location parameters');
  67. }
  68. }
  69. }
  70. }
  71. $paths = array();
  72. if($pathsTemp=k($params,'path')) {
  73. if(!is_array($pathsTemp))
  74. $pathsTemp = array($pathsTemp);
  75. foreach($pathsTemp as $i=>$path) {
  76. $properties = array();
  77. if(preg_match_all('/(?P<k>[a-z]+):(?P<v>[^;]+)/', $path, $matches)) {
  78. foreach($matches['k'] as $j=>$key) {
  79. $properties[$key] = $matches['v'][$j];
  80. }
  81. }
  82. // Set default color and weight if none specified
  83. if(!array_key_exists('color', $properties))
  84. $properties['color'] = '333333';
  85. if(!array_key_exists('weight', $properties))
  86. $properties['weight'] = 3;
  87. // Now parse the points into an array
  88. if(preg_match_all('/(?P<point>\[[0-9\.-]+,[0-9\.-]+\])/', $path, $matches)) {
  89. $properties['path'] = json_decode('[' . implode(',', $matches['point']) . ']');
  90. // Adjust the bounds to fit the path
  91. foreach($properties['path'] as $point) {
  92. if($point[1] < $bounds['minLat'])
  93. $bounds['minLat'] = $point[1];
  94. if($point[1] > $bounds['maxLat'])
  95. $bounds['maxLat'] = $point[1];
  96. if($point[0] < $bounds['minLng'])
  97. $bounds['minLng'] = $point[0];
  98. if($point[0] > $bounds['maxLng'])
  99. $bounds['maxLng'] = $point[0];
  100. }
  101. }
  102. if(array_key_exists('path', $properties))
  103. $paths[] = $properties;
  104. }
  105. }
  106. $defaultLatitude = $bounds['minLat'] + (($bounds['maxLat'] - $bounds['minLat']) / 2);
  107. $defaultLongitude = $bounds['minLng'] + (($bounds['maxLng'] - $bounds['minLng']) / 2);
  108. if(k($params,'latitude') !== false) {
  109. $latitude = k($params,'latitude');
  110. $longitude = k($params,'longitude');
  111. } elseif(k($params,'location') !== false) {
  112. $result = ArcGISGeocoder::geocode(k($params,'location'));
  113. if(!$result->success) {
  114. $latitude = $defaultLatitude;
  115. $longitude = $defaultLongitude;
  116. #header('X-Geocode: error');
  117. #header('X-Geocode-Result: ' . $result->raw);
  118. } else {
  119. $latitude = $result->latitude;
  120. $longitude = $result->longitude;
  121. #header('X-Geocode: success');
  122. #header('X-Geocode-Result: ' . $latitude . ', ' . $longitude);
  123. }
  124. } else {
  125. $latitude = $defaultLatitude;
  126. $longitude = $defaultLongitude;
  127. }
  128. $width = k($params, 'width', 300);
  129. $height = k($params, 'height', 300);
  130. // If no zoom is specified, choose a zoom level that will fit all the markers and the path
  131. if(k($params,'zoom')) {
  132. $zoom = k($params,'zoom');
  133. } else {
  134. // start at max zoom level (20)
  135. $fitZoom = 21;
  136. $doesNotFit = true;
  137. while($fitZoom > 1 && $doesNotFit) {
  138. $fitZoom--;
  139. $center = webmercator\latLngToPixels($latitude, $longitude, $fitZoom);
  140. $leftEdge = $center['x'] - $width/2;
  141. $topEdge = $center['y'] - $height/2;
  142. // check if the bounding rectangle fits within width/height
  143. $sw = webmercator\latLngToPixels($bounds['minLat'], $bounds['minLng'], $fitZoom);
  144. $ne = webmercator\latLngToPixels($bounds['maxLat'], $bounds['maxLng'], $fitZoom);
  145. // leave some padding around the objects
  146. $fitHeight = abs($ne['y'] - $sw['y']) + (0.1 * $height);
  147. $fitWidth = abs($ne['x'] - $sw['x']) + (0.1 * $width);
  148. if($fitHeight <= $height && $fitWidth <= $width) {
  149. $doesNotFit = false;
  150. }
  151. }
  152. $zoom = $fitZoom;
  153. }
  154. if(k($params,'maxzoom') && k($params,'maxzoom') < $zoom) {
  155. $zoom = k($params,'maxzoom');
  156. }
  157. $minZoom = 2;
  158. if($zoom < $minZoom)
  159. $zoom = $minZoom;
  160. $tileServices = array(
  161. 'streets' => array(
  162. 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{Z}/{Y}/{X}'
  163. ),
  164. 'satellite' => array(
  165. 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{Z}/{Y}/{X}'
  166. ),
  167. 'hybrid' => array(
  168. 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{Z}/{Y}/{X}',
  169. 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{Z}/{Y}/{X}'
  170. ),
  171. 'topo' => array(
  172. 'http://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{Z}/{Y}/{X}'
  173. ),
  174. 'gray' => array(
  175. 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{Z}/{Y}/{X}',
  176. 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{Z}/{Y}/{X}'
  177. ),
  178. 'gray-background' => array(
  179. 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{Z}/{Y}/{X}',
  180. ),
  181. 'oceans' => array(
  182. 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean_Basemap/MapServer/tile/{Z}/{Y}/{X}'
  183. ),
  184. 'national-geographic' => array(
  185. 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{Z}/{Y}/{X}'
  186. ),
  187. 'osm' => array(
  188. 'https://tile.openstreetmap.org/{Z}/{X}/{Y}.png'
  189. ),
  190. 'stamen-toner' => array(
  191. 'http://tile.stamen.com/toner/{Z}/{X}/{Y}.png'
  192. ),
  193. 'stamen-toner-background' => array(
  194. 'http://tile.stamen.com/toner-background/{Z}/{X}/{Y}.png'
  195. ),
  196. 'stamen-toner-lite' => array(
  197. 'http://tile.stamen.com/toner-lite/{Z}/{X}/{Y}.png'
  198. ),
  199. 'stamen-terrain' => array(
  200. 'http://tile.stamen.com/terrain/{Z}/{X}/{Y}.png'
  201. ),
  202. 'stamen-terrain-background' => array(
  203. 'http://tile.stamen.com/terrain-background/{Z}/{X}/{Y}.png'
  204. ),
  205. 'stamen-watercolor' => array(
  206. 'http://tile.stamen.com/watercolor/{Z}/{X}/{Y}.png'
  207. ),
  208. );
  209. if(k($params,'basemap') && k($tileServices, k($params,'basemap'))) {
  210. $tileURL = $tileServices[k($params,'basemap')][0];
  211. if(array_key_exists(1, $tileServices[k($params,'basemap')]))
  212. $overlayURL = $tileServices[k($params,'basemap')][1];
  213. else
  214. $overlayURL = 0;
  215. } elseif(k($params, 'basemap') == 'custom' && $is_authenticated) {
  216. $tileURL = $params['tileurl'];
  217. $overlayURL = false;
  218. } else {
  219. $tileURL = $tileServices['gray'][0];
  220. $overlayURL = false;
  221. }
  222. function urlForTile($x, $y, $z, $tileURL) {
  223. return str_replace(array(
  224. '{X}', '{Y}', '{Z}', '{x}', '{y}', '{z}'
  225. ), array(
  226. $x, $y, $z, $x, $y, $z
  227. ), $tileURL);
  228. }
  229. $im = imagecreatetruecolor($width, $height);
  230. // Find the pixel coordinate of the center of the map
  231. $center = webmercator\latLngToPixels($latitude, $longitude, $zoom);
  232. $leftEdge = $center['x'] - $width/2;
  233. $topEdge = $center['y'] - $height/2;
  234. $tilePos = webmercator\pixelsToTile($center['x'], $center['y']);
  235. // print_r($tilePos);
  236. // echo '<br />';
  237. $pos = webmercator\positionInTile($center['x'], $center['y']);
  238. // print_r($pos);
  239. // echo '<br />';
  240. // For the given number of pixels, determine how many tiles are needed in each direction
  241. $neTile = webmercator\pixelsToTile($center['x'] + $width/2, $center['y'] + $height/2);
  242. // print_r($neTile);
  243. // echo '<br />';
  244. $swTile = webmercator\pixelsToTile($center['x'] - $width/2, $center['y'] - $height/2);
  245. // print_r($swTile);
  246. // echo '<br />';
  247. // Now download all the tiles
  248. $tiles = array();
  249. $overlays = array();
  250. $chs = array();
  251. $mh = curl_multi_init();
  252. $numTiles = 0;
  253. $urls = array();
  254. for($x = $swTile['x']; $x <= $neTile['x']; $x++) {
  255. $x = (int)$x;
  256. if(!array_key_exists($x, $tiles)) {
  257. $tiles[$x] = array();
  258. $overlays[$x] = array();
  259. $chs[$x] = array();
  260. $ochs[$x] = array();
  261. }
  262. for($y = $swTile['y']; $y <= $neTile['y']; $y++) {
  263. $y = (int)$y;
  264. $url = urlForTile($x, $y, $zoom, $tileURL);
  265. $urls[] = $url;
  266. $tiles[$x][$y] = false;
  267. $chs[$x][$y] = curl_init($url);
  268. curl_setopt($chs[$x][$y], CURLOPT_RETURNTRANSFER, TRUE);
  269. curl_setopt($chs[$x][$y], CURLOPT_FOLLOWLOCATION, true);
  270. curl_multi_add_handle($mh, $chs[$x][$y]);
  271. if($overlayURL) {
  272. $url = urlForTile($x, $y, $zoom, $overlayURL);
  273. $overlays[$x][$y] = false;
  274. $ochs[$x][$y] = curl_init($url);
  275. curl_setopt($ochs[$x][$y], CURLOPT_RETURNTRANSFER, TRUE);
  276. curl_setopt($ochs[$x][$y], CURLOPT_FOLLOWLOCATION, TRUE);
  277. curl_multi_add_handle($mh, $ochs[$x][$y]);
  278. }
  279. $numTiles++;
  280. }
  281. }
  282. $running = null;
  283. // Execute the handles. Blocks until all are finished.
  284. do {
  285. $mrc = curl_multi_exec($mh, $running);
  286. } while($running > 0);
  287. // In case any of the tiles fail, they will be grey instead of throwing an error
  288. $blank = imagecreatetruecolor(256, 256);
  289. $grey = imagecolorallocate($im, 224, 224, 224);
  290. imagefill($blank, 0,0, $grey);
  291. foreach($chs as $x=>$yTiles) {
  292. foreach($yTiles as $y=>$ch) {
  293. $content = curl_multi_getcontent($ch);
  294. $tiles[$x][$y] = @imagecreatefromstring($content);
  295. if(!$tiles[$x][$y])
  296. $tiles[$x][$y] = $blank;
  297. }
  298. }
  299. if($overlayURL) {
  300. foreach($ochs as $x=>$yTiles) {
  301. foreach($yTiles as $y=>$ch) {
  302. $content = curl_multi_getcontent($ch);
  303. $overlays[$x][$y] = @imagecreatefromstring($content);
  304. if(!$overlays[$x][$y])
  305. $overlays[$x][$y] = $blank;
  306. }
  307. }
  308. }
  309. // Assemble all the tiles into a new image positioned as appropriate
  310. foreach($tiles as $x=>$yTiles) {
  311. foreach($yTiles as $y=>$tile) {
  312. $x = intval($x);
  313. $y = intval($y);
  314. $ox = (($x - $tilePos['x']) * TILE_SIZE) - $pos['x'] + ($width/2);
  315. $oy = (($y - $tilePos['y']) * TILE_SIZE) - $pos['y'] + ($height/2);
  316. imagecopy($im, $tile, $ox,$oy, 0,0, imagesx($tile),imagesy($tile));
  317. }
  318. }
  319. if($overlayURL) {
  320. foreach($overlays as $x=>$yTiles) {
  321. foreach($yTiles as $y=>$tile) {
  322. $x = intval($x);
  323. $y = intval($y);
  324. $ox = (($x - $tilePos['x']) * TILE_SIZE) - $pos['x'] + ($width/2);
  325. $oy = (($y - $tilePos['y']) * TILE_SIZE) - $pos['y'] + ($height/2);
  326. imagecopy($im, $tile, $ox,$oy, 0,0, imagesx($tile),imagesy($tile));
  327. }
  328. }
  329. }
  330. if(count($paths)) {
  331. // Draw the path with ImageMagick because GD sucks as anti-aliased lines
  332. $mg = new Imagick();
  333. $mg->newImage($width, $height, new ImagickPixel('none'));
  334. $draw = new ImagickDraw();
  335. $colors = array();
  336. foreach($paths as $path) {
  337. $draw->setStrokeColor(new ImagickPixel('#'.$path['color']));
  338. $draw->setStrokeWidth($path['weight']);
  339. $draw->setFillOpacity(0);
  340. $draw->setStrokeLineCap(Imagick::LINECAP_ROUND);
  341. $draw->setStrokeLineJoin(Imagick::LINEJOIN_ROUND);
  342. $previous = false;
  343. foreach($path['path'] as $point) {
  344. if($previous) {
  345. $from = webmercator\latLngToPixels($previous[1], $previous[0], $zoom);
  346. $to = webmercator\latLngToPixels($point[1], $point[0], $zoom);
  347. if(k($params, 'bezier')) {
  348. $x_dist = abs($from['x'] - $to['x']);
  349. $y_dist = abs($from['y'] - $to['y']);
  350. // If the X distance is longer than Y distance, draw from left to right
  351. if($x_dist > $y_dist) {
  352. // Draw from left to right
  353. if($from['x'] > $to['x']) {
  354. $tmpFrom = $from;
  355. $tmpTo = $to;
  356. $from = $tmpTo;
  357. $to = $tmpFrom;
  358. unset($tmp);
  359. }
  360. } else {
  361. // Draw from top to bottom
  362. if($from['y'] > $to['y']) {
  363. $tmpFrom = $from;
  364. $tmpTo = $to;
  365. $from = $tmpTo;
  366. $to = $tmpFrom;
  367. unset($tmp);
  368. }
  369. }
  370. $angle = 1 * k($params, 'bezier');
  371. // Midpoint between the two ends
  372. $M = [
  373. 'x' => ($from['x'] + $to['x']) / 2,
  374. 'y' => ($from['y'] + $to['y']) / 2
  375. ];
  376. // Derived from http://math.stackexchange.com/a/383648 and http://www.wolframalpha.com/input/?i=triangle+%5B1,1%5D+%5B5,2%5D+%5B1-1%2Fsqrt(3),1%2B4%2Fsqrt(3)%5D
  377. // See for details
  378. $A = $from;
  379. $B = $to;
  380. $P = [
  381. 'x' => ($M['x']) - (($A['y']-$M['y']) * tan(deg2rad($angle))),
  382. 'y' => ($M['y']) + (($A['x']-$M['x']) * tan(deg2rad($angle)))
  383. ];
  384. $draw->pathStart();
  385. $draw->pathMoveToAbsolute($A['x']-$leftEdge,$A['y']-$topEdge);
  386. $draw->pathCurveToQuadraticBezierAbsolute(
  387. $P['x']-$leftEdge, $P['y']-$topEdge,
  388. $B['x']-$leftEdge, $B['y']-$topEdge
  389. );
  390. $draw->pathFinish();
  391. } else {
  392. $draw->line($from['x']-$leftEdge,$from['y']-$topEdge, $to['x']-$leftEdge,$to['y']-$topEdge);
  393. }
  394. }
  395. $previous = $point;
  396. }
  397. }
  398. $mg->drawImage($draw);
  399. $mg->setImageFormat("png");
  400. $pathImg = imagecreatefromstring($mg);
  401. imagecopy($im, $pathImg, 0,0, 0,0, $width,$height);
  402. }
  403. // Add markers
  404. foreach($markers as $marker) {
  405. // Icons that have 'dot' in the name do not have a shadow and center vertically and horizontally
  406. $shadow = !preg_match('/dot/', $marker['icon']);
  407. if($width < 120 || $height < 120) {
  408. $shrinkFactor = 1.5;
  409. } else {
  410. $shrinkFactor = 1;
  411. }
  412. // Icons with a shadow are centered at the bottom middle pixel.
  413. // Icons with no shadow are centered in the center pixel.
  414. $px = webmercator\latLngToPixels($marker['lat'], $marker['lng'], $zoom);
  415. $pos = array(
  416. 'x' => $px['x'] - $leftEdge,
  417. 'y' => $px['y'] - $topEdge
  418. );
  419. if($shrinkFactor > 1) {
  420. $markerImg = imagecreatetruecolor(round(imagesx($marker['iconImg'])/$shrinkFactor), round(imagesy($marker['iconImg'])/$shrinkFactor));
  421. imagealphablending($markerImg, true);
  422. $color = imagecolorallocatealpha($markerImg, 0, 0, 0, 127);
  423. imagefill($markerImg, 0,0, $color);
  424. imagecopyresampled($markerImg, $marker['iconImg'], 0,0, 0,0, imagesx($markerImg),imagesy($markerImg), imagesx($marker['iconImg']),imagesy($marker['iconImg']));
  425. } else {
  426. $markerImg = $marker['iconImg'];
  427. }
  428. if($shadow) {
  429. $iconPos = array(
  430. 'x' => $pos['x'] - round(imagesx($markerImg)/2),
  431. 'y' => $pos['y'] - imagesy($markerImg)
  432. );
  433. } else {
  434. $iconPos = array(
  435. 'x' => $pos['x'] - round(imagesx($markerImg)/2),
  436. 'y' => $pos['y'] - round(imagesy($markerImg)/2)
  437. );
  438. }
  439. imagecopy($im, $markerImg, $iconPos['x'],$iconPos['y'], 0,0, imagesx($markerImg),imagesy($markerImg));
  440. }
  441. if(k($params,'attribution') == 'mapbox') {
  442. $logo = imagecreatefrompng($assetPath . '/mapbox-attribution.png');
  443. imagecopy($im, $logo, $width-imagesx($logo), $height-imagesy($logo), 0,0, imagesx($logo),imagesy($logo));
  444. } elseif(k($params,'attribution') == 'mapbox-small') {
  445. $logo = imagecreatefrompng($assetPath . '/mapbox-attribution.png');
  446. $shrinkFactor = 2;
  447. imagecopyresampled($im, $logo, $width-round(imagesx($logo)/$shrinkFactor), $height-round(imagesy($logo)/$shrinkFactor), 0,0, round(imagesx($logo)/$shrinkFactor),round(imagesy($logo)/$shrinkFactor), imagesx($logo),imagesy($logo));
  448. } elseif(k($params,'attribution') != 'none') {
  449. $logo = imagecreatefrompng($assetPath . '/powered-by-esri.png');
  450. // Shrink the logo if the image is small
  451. if($width > 120) {
  452. if($width < 220 || k($params, 'attribution') == 'small') {
  453. $shrinkFactor = 2;
  454. imagecopyresampled($im, $logo, $width-round(imagesx($logo)/$shrinkFactor)-4, $height-round(imagesy($logo)/$shrinkFactor)-4, 0,0, round(imagesx($logo)/$shrinkFactor),round(imagesy($logo)/$shrinkFactor), imagesx($logo),imagesy($logo));
  455. } else {
  456. imagecopy($im, $logo, $width-imagesx($logo)-4, $height-imagesy($logo)-4, 0,0, imagesx($logo),imagesy($logo));
  457. }
  458. }
  459. }
  460. #header('Cache-Control: max-age=' . (60*60*24*30) . ', public');
  461. #header('X-Tiles-Downloaded: ' . $numTiles);
  462. // TODO: add caching
  463. $fmt = k($params,'format', 'png');
  464. switch($fmt) {
  465. case "jpg":
  466. case "jpeg":
  467. header('Content-type: image/jpg');
  468. $quality = k($params, 'quality', 75);
  469. imagejpeg($im, $filename, $quality);
  470. break;
  471. case "png":
  472. default:
  473. header('Content-type: image/png');
  474. imagepng($im, $filename);
  475. break;
  476. }
  477. imagedestroy($im);
  478. /**
  479. * http://msdn.microsoft.com/en-us/library/bb259689.aspx
  480. * http://derickrethans.nl/php-mapping.html
  481. */
  482. }
  483. function k($a, $k, $default=false) {
  484. if(is_array($a) && array_key_exists($k, $a) && $a[$k])
  485. return $a[$k];
  486. elseif(is_object($a) && property_exists($a, $k) && $a->$k)
  487. return $a->$k;
  488. else
  489. return $default;
  490. }