Azure map - static image lat/lon x/y conversion

Aidan Booker 41 Reputation points
2022-06-29T14:04:34.86+00:00

Good afternoon!

We've found the static image render very useful, but I'm missing some details to make it perfect...

I'll use Inverness as an example as I happen to have it to hand:

https://atlas.microsoft.com/map/static/png?api-version=1.0&layer=basic&style=main&zoom=16&center=-4.227474137954744,57.48290581383472&width=8192&height=8192&subscription-key=xyz

That's lovely, gives us an 8k image that we can start to draw things on.

For ease of reading, thats:
zoom: 16
width/height: 8196,
center: -4.227474137954744, 57.48290581383472

Before I move on, I'll confirm that I already know we could give it some shapes and things and it'll draw it all on for us, but we're not going to be doing that.

That said, we do want to draw some things on.

Given some point, lets use this one:
-4.223527, 57.495676

It's near the tip, where the road crosses the water.

Now, I have some moderately simple maths in place to switch from the lon/lat of the point to an x/y on the image; a simple percentage kind of deal. If a lon is 80% all the way to the right at a lon, then it's 80% of the 8192 pixels across.

This works fine... so long as you know where the terminating bounds of the image are.

I've managed to guess fairly well, using the supported max lat/lon values supplied on the render page. I figured that if I know where my center is, and my zoom level, the biggest image allowed to me - were I to be using a bbox - is using those limits, then presumably the same limits would apply to my center version of things.

So for zoom level 16, max lon/lat = 0.087890625, 0.0415039063.

Sweet. Center point + half of those values gives me my bounding box.

Now, this is shocking well for lon as-is (at least for this one area I'm concentrating my efforts on for consistency), but it falls over with lat.

For example; guess work puts the top bound at 57.48290581385372. Taking a look at slapping my mouse at an approximately the same point to see where the top edge really is puts it at about 57.506485.

A difference of 0.12ish. Quite a lot to be off. So my items end up being stretched further up (or down) the image the further from the center point they get.

Is there a proper way (short of sending markers/shapes with the request) to handle lon/lat-x/y conversions for this? My method works, so long as I know the bounds, but I don't know the bounds - the real bounds - as the image has no associated data with it. I simply request data around a center point and a zoom level but I don't really know what it's going to return.

Before mentioning it, I do not plan on using a bounding box for the image request - I realise this would obviously give me the image bounds, but it's not suitable for what we're doing.

Thanks.

Azure Maps
Azure Maps
An Azure service that provides geospatial APIs to add maps, spatial analytics, and mobility solutions to apps.
831 questions
0 comments No comments
{count} votes

Accepted answer
  1. rbrundritt 20,836 Reputation points Microsoft Employee Moderator
    2022-06-29T16:27:05.15+00:00

    What you are encountering is due to the Mercator projection of the map. Longitude degrees at a given latitude is fairly constant and you can get away with ratios, while latitude stretches out as you move towards the poles, as shown in the image below.

    The-web-Mercator-projection.png

    As such, you have to do a bit more trigonometry to accurately cross reference points on the image. The image is created based on the tile pyramid used by the map as described here: https://learn.microsoft.com/en-us/azure/azure-maps/zoom-levels-and-tile-grid

    Here is a high-level breakdown of what needs to be done:

    1. Calculate the Mercator pixel position of the center of the map relative to zoom level and top left corner of globe (-180 lon, 90 lat).
    2. Divide the width and height by two and subtract these from x, y respectively. This will give you the top left corner of the map image in pixels relative to the top left corner of the globe.
    3. Calculate the Mercator pixel position of all your points you want to draw relative to zoom level and top left corner of globe. Then for each one, subtract the top left corner pixel calculated above. This will give you the local pixel coordinates in the image where the top left corner of the image is 0,0, and the bottom right corner is the maps width/height.

    To keep things a bit simpler, below if a simple web app I put together that shows how to do this. It draws a red circle in the middle of two round abouts in the image (made it easier to compare to points I picked on another map for testing).

       <!DOCTYPE html>  
       <html>  
       <head>  
           <meta charset="utf-8" />  
           <title></title>  
       </head>  
       <body>  
           <canvas id="myCanvas"></canvas>  
           <script>  
               //Inputs  
               var azureMapsKey = '<Your Azure Maps Key>';  
         
               //API version of the render service to use.  
               //var apiVersion = '1.0';   
               var apiVersion = '2.1';  
         
               var longitude = -4.227474137954744;  
               var latitude = 57.48290581383472;  
               var zoom = 16;  
         
               var layer = 'basic';  
               var style = 'main';  
         
               //Dimensions of the image  
               var width = 8192;  
               var height = 8192;  
         
               var tileSize = 512; //Azure Maps uses 512 sized tiles. Other platforms may use 256.  
         
               //Get the canvas and set its size.  
               var canvas = document.getElementById('myCanvas');  
               canvas.width = width;  
               canvas.height = height;  
         
               var ctx = canvas.getContext('2d');  
         
               var imageObj = new Image();  
         
               imageObj.onload = function () {  
                   //Draw the mage on the canvas.  
                   ctx.drawImage(imageObj, 0, 0);  
         
                   //Calculate the global mercator pixel position of the center, based on zoom level and tile size.  
                   var centerPixel = TileMath.PositionToGlobalPixel([longitude, latitude], zoom, tileSize);  
         
                   //Calculate the top left corner of the image in global mercator pixels by offsetting relative to helf the images width/height.  
                   var topLeftPixel = [  
                       centerPixel[0] - width / 2, //x  
                       centerPixel[1] - height / 2 //y  
                   ];  
         
                   //Create a reusable function to convert a position to an local pixel coordinate in the image.  
                   var transform = (position) => {  
                       var pixel = TileMath.PositionToGlobalPixel(position, zoom, tileSize);  
         
                       pixel[0] -= topLeftPixel[0];  
                       pixel[1] -= topLeftPixel[1];  
         
                       return pixel;  
                   };  
         
                   //Draw data on the canvas.  
                   drawPoint(transform([-4.247135, 57.463845]));  
                   drawPoint(transform([-4.215133, 57.491767]));  
               };  
         
               //Request image.  
               imageObj.src = `https://atlas.microsoft.com/map/static/png?api-version=${apiVersion}&layer=${layer}&style=${style}&zoom=${zoom}&center=${longitude},${latitude}&width=${width}&height=${height}&subscription-key=${azureMapsKey}`;  
         
               function drawPoint(pixel) {  
                   var x = pixel[0];  
                   var y = pixel[1];  
         
                   var pointSize = 15;  
         
                   ctx.fillStyle = "#ff0000";   
         
                   ctx.beginPath();   
                   ctx.arc(x, y, pointSize, 0, Math.PI * 2, true);  
                   ctx.fill();   
               }  
         
               //A subset of the code here: https://learn.microsoft.com/en-us/azure/azure-maps/zoom-levels-and-tile-grid?tabs=typescript  
               class TileMath {  
                   //Earth radius in meters.  
                   static EarthRadius = 6378137;  
         
                   static MinLatitude = -85.05112878;  
                   static MaxLatitude = 85.05112878;  
                   static MinLongitude = -180;  
                   static MaxLongitude = 180;  
         
                   /**  
                    * Clips a number to the specified minimum and maximum values.  
                    * @param n The number to clip.  
                    * @param minValue Minimum allowable value.  
                    * @param maxValue Maximum allowable value.  
                    * @returns The clipped value.  
                    */  
                   static Clip(n, minValue, maxValue) {  
                       return Math.min(Math.max(n, minValue), maxValue);  
                   }  
         
                   /**  
                    * Calculates width and height of the map in pixels at a specific zoom level from -180 degrees to 180 degrees.  
                    * @param zoom Zoom Level to calculate width at.  
                    * @param tileSize The size of the tiles in the tile pyramid.  
                    * @returns Width and height of the map in pixels.  
                    */  
                   static MapSize(zoom, tileSize) {  
                       return Math.ceil(tileSize * Math.pow(2, zoom));  
                   }  
         
                   /**  
                    * Calculates the Ground resolution at a specific degree of latitude in the meters per pixel.  
                    * @param latitude Degree of latitude to calculate resolution at.  
                    * @param zoom Zoom level.  
                    * @param tileSize The size of the tiles in the tile pyramid.  
                    * @returns Ground resolution in meters per pixels.  
                    */  
                   static GroundResolution(latitude, zoom, tileSize) {  
                       latitude = this.Clip(latitude, this.MinLatitude, this.MaxLatitude);  
                       return Math.cos(latitude * Math.PI / 180) * 2 * Math.PI * this.EarthRadius / this.MapSize(zoom, tileSize);  
                   }  
         
                   /**  
                    * Determines the map scale at a specified latitude, level of detail, and screen resolution.  
                    * @param latitude Latitude (in degrees) at which to measure the map scale.  
                    * @param zoom Zoom level.  
                    * @param screenDpi Resolution of the screen, in dots per inch.  
                    * @param tileSize The size of the tiles in the tile pyramid.  
                    * @returns The map scale, expressed as the denominator N of the ratio 1 : N.  
                    */  
                   static MapScale(latitude, zoom, screenDpi, tileSize) {  
                       return this.GroundResolution(latitude, zoom, tileSize) * screenDpi / 0.0254;  
                   }  
         
                   /**  
                    * Global Converts a Pixel coordinate into a geospatial coordinate at a specified zoom level.  
                    * Global Pixel coordinates are relative to the top left corner of the map (90, -180).  
                    * @param pixel Pixel coordinates in the format of [x, y].  
                    * @param zoom Zoom level.  
                    * @param tileSize The size of the tiles in the tile pyramid.  
                    * @returns A position value in the format [longitude, latitude].  
                    */  
                   static GlobalPixelToPosition(pixel, zoom, tileSize) {  
                       var mapSize = this.MapSize(zoom, tileSize);  
         
                       var x = (this.Clip(pixel[0], 0, mapSize - 1) / mapSize) - 0.5;  
                       var y = 0.5 - (this.Clip(pixel[1], 0, mapSize - 1) / mapSize);  
         
                       return [  
                           360 * x,    //Longitude  
                           90 - 360 * Math.atan(Math.exp(-y * 2 * Math.PI)) / Math.PI  //Latitude  
                       ];  
                   }  
         
                   /**  
                    * Converts a point from latitude/longitude WGS-84 coordinates (in degrees) into pixel XY coordinates at a specified level of detail.  
                    * @param position Position coordinate in the format [longitude, latitude].  
                    * @param zoom Zoom level.  
                    * @param tileSize The size of the tiles in the tile pyramid.  
                    * @returns A pixel coordinate   
                    */  
                   static PositionToGlobalPixel(position, zoom, tileSize) {  
                       var latitude = this.Clip(position[1], this.MinLatitude, this.MaxLatitude);  
                       var longitude = this.Clip(position[0], this.MinLongitude, this.MaxLongitude);  
         
                       var x = (longitude + 180) / 360;  
                       var sinLatitude = Math.sin(latitude * Math.PI / 180);  
                       var y = 0.5 - Math.log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI);  
         
                       var mapSize = this.MapSize(zoom, tileSize);  
         
                       return [  
                           this.Clip(x * mapSize + 0.5, 0, mapSize - 1),  
                           this.Clip(y * mapSize + 0.5, 0, mapSize - 1)  
                       ];  
                   }  
               }  
           </script>  
       </body>  
       </html>  
    

    This will work in most scenarios. The one edge case is when your image crosses the anti-Merdian (-180/180 longitude). To handle that situation there is more math involved. Here is a high-level summary:

    1. Determine which side of the anti-Merdian your center point is in your image. When calculating the top left global pixel, you would likely find in this situation that the x value is either negative, or greater than the maximum maps pixel width for the zoom level.
    2. Add or subtract the maps full width in pixels for the zoom level from the calculated global pixel coordinate.
    3. A naive approach is to calculate three points for three "globes" and draw them all; subtract the map width from x for one point, use x as is for another, and add map width from x for the third point.
    4. Lines/polygons get a bit trickier as you then need to decide if you want the line to tack the shortest pixel path and then you need to look at pairs of pixels and decide if you head left or right.
    1 person found this answer helpful.

0 additional answers

Sort by: Most helpful

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.