Geo coordinates to pixel positions – build a map with your own images

This blog post will be a start of a little post series. For a project, I recently had to add markers of cities to a map of Germany. I did that manually in the past, but as the maps has grown to 100 markers, I was looking for a way to do that programmatically.

Map projections

Even though some people believe the earth is flat, the earth is round. But it is also not perfectly round. As our monitors are flat (and before that paper maps), there are man different “map projections“. One of the most common ones is the Mercator projection which is used by Google Maps, Bing Maps, OpenStreetMap and many other services. It’s also referred to by identifier EPSG:3857, which we will need a little later.

GPS coordinates

When giving the position of a place on earth, you usually use the World Geodetic System 1984 (WGS 84) with the latitude and longitude values for a specific place. For the Brandenburg Gate in Berlin this might be around 52.5162746 (latitude) and 13.3777041 (longitude). You might have seen coordinates like this before. You might also have seen a location in degrees, minutes and seconds like 52° 30' 58.59" (latitude) and 13° 22' 39.7338" (longitude). But as it’s easier to make calculations with numbers, the first format is easier to continue with.

Getting SVG maps

So now that we have talked a bit about the theory, let’s talk about code. First, you need to find a map. This can be anything, an aerial photo, a satellite image or an image. I like to use SVG images. You can find them on different platforms. Wikipedia is usually a good source, and maps are usually released using a Creative Commons license. We can find a nice map of Germany with borders to all states. Unfortunately, it uses a different projection system. That’s why, for this blog post, I have chosen the free SVG map from amCharts:

Map of Germany
© ammap.com | SVG map of Germany (low detail), code optimized by Bernhard Kau, CC BY-NC 4.0

There are also commercial services and special stock shops for vector maps. Which ever map you use, please make sure to respect the license.

Geocoding markers

We have a map, let’s start to add some markers. As you probably only know the GPS coordinates, we need a way to convert them to “x/y pixel coordinates” on the SVG map. After some research I’ve found the PROJ library and fortunately there is also a PHP variant of PROJ. We can install it using composer:

composer require proj4php/proj4php

Now we have to do some initialize. We have are converting between two projection systems which we have to set up as follow:

require 'vendor/autoload.php';

// Initialize the PROJ library.
$proj = new Proj4php();

// Set the source and destination projection.
$src_projection = new Proj( 'EPSG:4326', $proj );
$dst_projection = new Proj( 'EPSG:3857', $proj );

The EPSG:4326 projection system, our source projection, is the WGS 84 system used for GPS coordinates. The EPSG:3857 on the other hand is a “Spherical Mercator” projection system mentioned above and also for the SVG file we are using here. So the first one is defining coordinates on a “globe” (spherical map) while the second one is defining coordinates on a flat map (in one of the many projection systems).

Now that we have this setup, let’s do the first conversion. We take the GPS coordinates of Berlin, Germany and convert them to the Mercator coordinates:

$src_berlin = new Point( 13.3777041, 52.5162746, $src_projection );
print_r( $src_berlin->toArray() );
/**
 * Array
 * (
 *     [0] => 52.5162746
 *     [1] => 13.3777041
 *     [2] => 0
 * )
 */

$dst_berlin = $proj->transform( $dst_projection, $src_berlin );
print_r( $dst_berlin->toArray() );
/**
 * Array
 * (
 *     [0] => 1489199.2083951
 *     [1] => 6894018.2850289
 *     [2] => 0
 * )
 */

All right, we have some new numbers now. But how would you use them now? If we want to put them on the SVG map, we have to calculate the pixel coordinates on that image.

Getting the image boundaries

Now we need to get the min and max coordinates on the map. If we are lucky, we would get the coordinates of these points from the provider of the map. Unfortunately for the map from Wikipedia there are no such coordinates given. You can probably find those boundaries on with a search engine or use an open API like the one from OpenStreetMaps and other services. But if you don’t have such data or don’t know where to find them, you can just try to get a GPS coordinate for the points on the edges. So for the map of Germany, we would need to find the places all the way in the North, East, South and West. With these points, we then use the min/max values for the latitude and longitude. Fortunately for the amCharts map, they have it in the source code of the SVG itself:

<amcharts:ammap projection="mercator" leftLongitude="5.864765" topLatitude="55.051693" rightLongitude="15.043380" bottomLatitude="47.269299"></amcharts:ammap>

With these, we can just use two reference points (that are not within the country limits of Germany, but that’s not an issue) and do our calculation:

$swap_x = false;
$swap_y = true;

$dst_points_x = [];
$dst_points_y = [];

$src_points = [
	[ 15.043380, 47.269133 ],
	[ 5.865010, 55.057722 ],
];

foreach ( $src_points as $point ) {
	$src_point      = new Point( $point[0], $point[1], $src_projection );
	$dst_point      = $proj->transform( $dst_projection, $src_point );
	$dst_point_arr  = $dst_point->toArray();
	$dst_points_x[] = $dst_point_arr[0];
	$dst_points_y[] = $dst_point_arr[1];
}

$src_boundaries = [
	'xmin' => $swap_x ? max( $dst_points_x ) : min( $dst_points_x ),
	'xmax' => $swap_x ? min( $dst_points_x ) : max( $dst_points_x ),
	'ymin' => $swap_y ? max( $dst_points_y ) : min( $dst_points_y ),
	'ymax' => $swap_y ? min( $dst_points_y ) : max( $dst_points_y ),
];

var_dump( $src_boundaries );
/**
 * array(4) {
 *   ["xmin"]=>
 *   float(653037.4250227585)
 *   ["xmax"]=>
 *   float(1668369.9214457471)
 *   ["ymin"]=>
 *   float(5986273.409259587)
 *   ["ymax"]=>
 *   float(7373214.063855921)
 * }
 */

With the two “swap” flags, you can indicate that the coordinates are on opposite sites of 0° latitude or 0° longitude, so the maximum value would be used for the left boundary for example and the minimum for the right boundary. For Germany, we have to set $swap_y to true.

Now that we have the boundaries, we can calculate the coordinates on the image. We would also need the size of the image. Then the calculation would be like this:

$image_boundaries = [
	'xmin' => 0,
	'xmax' => 585.506,
	'ymin' => 0,
	'ymax' => 791.999,
];

$dst_berlin_arr = $dst_berlin->toArray();
$lng = $dst_berlin_arr[0];
$lat = $dst_berlin_arr[1];

$x_pos = ( $lng - $src_boundaries['xmin'] ) / ( $src_boundaries['xmax'] - $src_boundaries['xmin'] ) * ( $image_boundaries['xmax'] - $image_boundaries['xmin'] );

$y_pos = ( $lat - $src_boundaries['ymin'] ) / ( $src_boundaries['ymax'] - $src_boundaries['ymin'] ) * ( $image_boundaries['ymax'] - $image_boundaries['ymin'] );

var_dump( [ $x_pos, $y_pos ] );
/**
 * array(2) {
 *   [0]=>
 *   float(487.1242093149932)
 *   [1]=>
 *   float(523.9253760603847)
 * }
 */

First, we define the boundaries of our SVG map of Germany. Then we use the $dst_berlin point we have transformed before and put it into our calculation. By using $src_boundaries['xmax'] - $src_boundaries['xmin'] we get the “width” of the source projection boundaries (same for the image boundaries). We then subtract the left boundary from the longitude of the point. We divide this point by the width of the source projection and multiply it with the width of the destination projection. This will give us the pixel position on the image. We do the same for the y-axis. To visualize the point, we could simply draw a SVG circle. This would then look like this in code:

<circle cx="482.18464676347816" cy="273.64009871474894" r="5" stroke="red" stroke-width="2" fill="transparent" />

We then add this new node to the original SVG map and get the following:

Map of Germany with a circle on Berlin
© ammap.com | SVG map of Germany (low detail), code optimized and modified by Bernhard Kau, CC BY-NC 4.0

Putting it all together

In order to make this all a little easier, I’ve written a small PixelGeocoder class with the initialization steps, the helper function for the boundaries and a method to do get the coordinates for the image:

use proj4php\Proj4php;
use proj4php\Proj;
use proj4php\Point;

class PixelGeocoder {
	public $proj;
	public $src_proj;
	public $dst_proj;

	public $src_boundaries = [
		'xmin' => 0,
		'xmax' => 0,
		'ymin' => 0,
		'ymax' => 0,
	];

	public $image_boundaries = [
		'xmin' => 0,
		'xmax' => 0,
		'ymin' => 0,
		'ymax' => 0,
	];

	public function __construct( $src_proj_type = 'EPSG:4326', $dst_proj_type = 'EPSG:3857' ) {
		$this->proj     = new Proj4php();
		$this->src_proj = new Proj( $src_proj_type, $this->proj );
		$this->dst_proj = new Proj( $dst_proj_type, $this->proj );
	}

	public function setDstBoundaries( $points, $swap_x = false, $swap_y = false ) {
		$dst_points_x = [];
		$dst_points_y = [];

		foreach ( $points as $point ) {
			$dst_point      = $this->transformGPStoMapProjection( $point[0], $point[1] );
			$dst_points_x[] = $dst_point[0];
			$dst_points_y[] = $dst_point[1];
		}

		$this->src_boundaries = [
			'xmin' => $swap_x ? max( $dst_points_x ) : min( $dst_points_x ),
			'xmax' => $swap_x ? min( $dst_points_x ) : max( $dst_points_x ),
			'ymin' => $swap_y ? max( $dst_points_y ) : min( $dst_points_y ),
			'ymax' => $swap_y ? min( $dst_points_y ) : max( $dst_points_y ),
		];
	}

	public function transformGPStoMapProjection( $lng, $lat ) {
		$src_point = new Point( $lng, $lat, $this->src_proj );
		$dst_point = $this->proj->transform( $this->dst_proj, $src_point );

		return $dst_point->toArray();
	}

	public function calculateCoordinatesToPixel( $lng, $lat ) {
		return [
			( $lng - $this->src_boundaries['xmin'] ) / ( $this->src_boundaries['xmax'] - $this->src_boundaries['xmin'] ) * ( $this->image_boundaries['xmax'] - $this->image_boundaries['xmin'] ),
			( $lat - $this->src_boundaries['ymin'] ) / ( $this->src_boundaries['ymax'] - $this->src_boundaries['ymin'] ) * ( $this->image_boundaries['ymax'] - $this->image_boundaries['ymin'] ),
		];
	}
}

Now if you want to use this class, this is:

require_once 'vendor/autoload.php';
require_once 'PixelGeocoder.php';

// Init PixelGeocoder using WGS84 and Mercato projection.
$pixel_geocoder = new PixelGeocoder( 'EPSG:4326', 'EPSG:3857' );
// Set boundaries for the map.
$pixel_geocoder->image_boundaries = [
	'xmin' => 0,
	'xmax' => 585.506,
	'ymin' => 0,
	'ymax' => 791.999,
];
$pixel_geocoder->setDstBoundaries(
	[
		[ 15.043380, 47.269133 ],
		[ 5.865010, 55.057722 ],
	],
	false,
	true
);

// Calculate the coordinates.
$berlin_lat     = 13.3777041;
$berlin_lng     = 52.5162746;
$dst_berlin_arr = $pixel_geocoder->transformGPStoMapProjection( $berlin_lat, $berlin_lng );
$image_coords   = $pixel_geocoder->calculateCoordinatesToPixel( $dst_berlin_arr[0], $dst_berlin_arr[1] );

var_dump( $image_coords );
/**
 * array(2) {
 *   [0]=>
 *   float(479.2493080704524)
 *   [1]=>
 *   float(273.55748351793665)
 * }
 */

Bonus: use a clickable marker

The circle is easy and nice, but if you want to build a map, you probably want to use a marker, and it should be clickable. Since we are using an SVG image here, we can use a path for the marker and then move it around with the transform="translate(x,y)" attribute:

<a xlink:title="Link to berlin.de" target="_parent" xlink:href="https://berlin.de/" transform="translate(479.2493080704524,273.55748351793665)">
	<path fill="#c10926" fill-rule="evenodd" d="m -0.266,-28.261 a 4.504,4.504 0 0 0 3.204,-1.343 4.613,4.613 0 0 0 1.327,-3.242 4.615,4.615 0 0 0 -1.327,-3.244 4.508,4.508 0 0 0 -3.204,-1.343 4.512,4.512 0 0 0 -3.206,1.343 4.619,4.619 0 0 0 -1.327,3.244 c 0,1.215 0.478,2.382 1.327,3.242 a 4.51,4.51 0 0 0 3.206,1.343 m -0.613,27.98 -8.895,-28.49 h 0.013 a 10.555,10.555 0 0 1 -0.818,-4.074 c 0,-2.77 1.086,-5.425 3.02,-7.381 a 10.251,10.251 0 0 1 7.294,-3.056 c 2.735,0 5.358,1.099 7.293,3.056 a 10.502,10.502 0 0 1 3.021,7.38 c 0,1.414 -0.284,2.798 -0.819,4.076 h 0.012 z" clip-rule="evenodd"/>
</a>

When added to our map, the result would look like this:

Map of Germany with a clickable marker on Berlin
© ammap.com | SVG map of Germany (low detail), code optimized and modified by Bernhard Kau, CC BY-NC 4.0

Conclusion

We all love map, don’t we? But sometimes a Google Maps or OpenStreetMap version just doesn’t look nice. With some custom code and a little set-up work, we can create beautiful maps on top of (our own) images. I did the above for a map of Germany that would get the GPS coordinates from a WordPress custom post type and then dynamically created the SVG markup for the city markers in a shortcode (and later server-side-rendered block).

If you want to test the code yourself, you can find it all on GitHub. It is a “proof of concept” and works, but the PHP class can probably use a better architecture and maybe some more methods, so please feel free to adjust it to your need.

In my next blog post, I will show you how you use a picture instead of an SVG map, so stay tuned, the year is not over, yet! ?

Posted by

Bernhard is a full time web developer who likes to write WordPress plugins in his free time and is an active member of the WP Meetups in Berlin and Potsdam.

Leave a Reply

Your email address will not be published. Required fields are marked *