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.
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:
- 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).
- 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.
- 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}¢er=${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:
- 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.
- Add or subtract the maps full width in pixels for the zoom level from the calculated global pixel coordinate.
- 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.
- 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.