Geographic Reporting, Part 3

In parts 1 and 2, we looked at capturing IP address information and retrieving a good-estimate latitude and longitude.  In this part, we'll plot that data on a map to create a nice looking demographic map of our visitors.  While we'll be doing this on a world map, the same principal can be applied to any map.

The biggest problem with plotting latitude and longitude is that there is no linear equation to represent a spherical coordinate on a flat plane (x and y).  Most software (and the method we'll use) plots data on a Mercator-style projected map.  On a Mercator map, the globe is projected as a flat rectangle – the most noticeable feature is that the lines of latitude and longitude are always perpendicular.  This makes it a bit easier mathematically (and navigationally, if you’re trying to find your heading) but does have a few drawbacks.  The biggest drawback is scale:  while longitude is uniformly spaced, the scale is only true at the equator (that is, 1:1).  As we move further north and south from the equator, the scale begins to grow as we approach the poles.  On a map that displays the lines of latitude, you can visually see this because the spacing increases while representing the same interval.  It's also obvious that Greenland, for example, is not the size of Africa (it's roughly 3 times smaller, actually), but it appears to be so on a Mercator map.  Fortunately, though, most people are "used" to this distortion and within 45 degrees from the equator (where most people live), the scale is reasonably true.

Another drawback is that a Mercator map cannot show the entire globe.  True 90 degree north or south is infinite (you can see calculus coming into play) and this limitation is not really relevant for our uses.   Full Mercator maps typically go to about +/- 86 degrees latitude.  

So, to plot latitude and longitude, we need to know a few arbitrary bits of information about our map that we'll use as a background (note: this will only work on Mercator maps).  The method I choose may not be what you choose, but it's a good start.  The information we need is the height and width of the map (in pixels), and the x and y point at which latitude and longitude are both zero.  The height and width could be read programmatically since we're having GDI+ load the image, but I’m leaving it for user input.  The reason is extensibility: if we wanted to do a map of just the United States, for example, we could provide an appropriate map and simply provide an "off-map" location of what would be 0,0 if the map were large enough.  The height and width can be overidden in this case, and it might be larger than the actual height and width of the image.  Another method would be to know the latitude and longitude of the upper left (or any corner) point of the map you’re using.  However, on a world map, this can be very difficult and the difference of one or two pixels near the poles can throw off the results astronomically.  Picking 0,0 allows the largest margin of error: you likely won’t notice any difference if you’re off a few pixels.  

On my Mercator map (720 x 418 pixels) the longitude is in the middle (at x-pixel 360) and the latitude is a bit off center, at y-pixel 148.  On a Mercator map, latitude and longitude can be determined according to these formulas:

x = r Longitude
y = r ln tan(pi/4 + Latitude/2)

where r is the scale of the map.

We can calculate the scale of our map using the radius – conveniently enough, the width of the map is our radius.  So, scale can be calculated like so:  

scale = width / 2pi  

Note, though, that not all all Mercator maps show a full 360 degrees longitude.  The map longitude extent can be used to scale this appropriately (see below).  One thing to remember: when plugging in latitude and longitude to the formulas above, don’t forget to convert the decimal value to radians.  That can be done like so:  

radians = value * (pi/180)  

Once we have this value, we’ll use our known location of 0,0 on the map to figure out the X,Y.  So let’s get real world (pun intended).   We need to store our map information, so here's a basic class to do that (obviously we should be good programmers and encapsulate these fields, but let’s keep it brief):

public class MapSettings
{
      public string SourceFilePath;
      public string DestinationFilePath;
      public int MapHeight;
      public int MapWidth;
      public int LatitudeZero;
      public int LongitudeZero;
      public int IndicatorSize = 4;
      public int LongitudeExtent = 360;
}

Most of those should be self explanatory -- LatitudeZero and LongitudeZero are the pixel offsets at which this coordinate can be found in the image.  IndicatorSize is the width (in pixels) each point will be.  The LongitudeExtent is the number of degrees the map shows.  Typically, this is 360 degrees.  Some Mercator maps don't show a full 360 (that is, 180 degrees east and 180 degrees west); if the view is less than 360 degrees, set this value appropriately. We'll use a struct to hold each point:

public struct Point
{
    public double Latitude;
    public double Longitude;
}

We'll use GDI+, of course, to draw on our map.  We’ll create a DrawMap method that takes an array of Point and a MapSettings type.  For each Point in the array, we'll locate the X and Y coordinates.  We’ll draw three objects: a drop shadow with some transparency, the main (filled) circle, and then use a pen to outline the circle to give better definition to the point.  We’ll then save the output.  (I also do a little work for ranking data and color coding – I won’t cover that here but that's an easy implementation.)   Creating a "do-it-all" draw method may look like: 

public static void DrawMap(MapSettings map, Point[] points)
{
      Bitmap bmp = null;
      Graphics g = null;

      try
      {
            bmp = new Bitmap(map.SourceFilePath);
            g = Graphics.FromImage(bmp);

            foreach (Point p in points)
            {
                  int CenterX;
                  int CenterY;

                  try
                  {
                        //calculate the radius of the map
                        double r = (map.MapWidth * 360/map.LongitudeExtent)
                               / (2 * Math.PI);

                        //calculate the height of the latitude
                        //using the formula above for latitude
                        double h = r * Math.Log(Math.Tan(
                              Convert.ToDouble(p.Latitude) *
                              (Math.PI/180)/2 + Math.PI/4)
                              );
                        //offset longitude according to the map settings
                        //using the formula above for longitude
                        CenterX = map.LongitudeZero + (int)Math.Round(
                              r * (Convert.ToDouble(p.Longitude) *
                              (Math.PI/180))
                              );
                        CenterY = map.LatitudeZero - (int)Math.Round(h);

                        g.SmoothingMode =
                              System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

                        //first a drop shadow
                        using (Brush DropBrush =
                              new SolidBrush(Color.FromArgb(70,0,0,0)))
                        {
                              g.FillEllipse(
                                    DropBrush,
                                    (CenterX+1)-(map.IndicatorSize/2F),
                                    (CenterY+1)-(map.IndicatorSize/2F),
                                    map.IndicatorSize+1,
                                    map.IndicatorSize+1);
                        }

                        //fill the circle
                        using (Brush FillBrush =
                              new SolidBrush(Color.White))
                        {
                              g.FillEllipse(
                                    FillBrush,
                                    CenterX-(map.IndicatorSize/2F),
                                    CenterY-(map.IndicatorSize/2F),
                                    map.IndicatorSize,
                                    map.IndicatorSize);
                        }

                        //trace an outline around the circle
                        using (Pen OutlinePen =
                              new Pen(Color.FromArgb(60,0,0,0)))
                        {
                              g.DrawEllipse(
                                    OutlinePen,
                                    CenterX-(map.IndicatorSize/2F),
                                    CenterY-(map.IndicatorSize/2F),
                                    map.IndicatorSize,
                                    map.IndicatorSize);
                        }
                  }
                  catch (Exception ex)
                  {
                        //implement handling as appropriate
                  }
            }

            bmp.Save(map.OutputFilePath,
                  System.Drawing.Imaging.ImageFormat.Png);

      }
      catch (Exception ex)
      {
            //implement handling as appropriate
      }
      finally
      {
            if (g != null) g.Dispose();
            if (bmp != null) bmp.Dispose();
      }
}

Of course, the first thing we’ll want to do is test.  You can load some data containing the locations of known cities and verify the results – or, if your map has the lines of latitude and longitude already, you can populate some dummy data, using this example below.

//populate some test data
Point[] allPoints = new Point[23*9];
int counter = 0;

for (int x = -165; x <= 165; x=x+15)
{
      for (int y = -45; y<=75; y=y+15)
      {
            Point p = new Point();
            p.Longitude = x;
            p.Latitude = y;
            allPoints[counter] = p;
            counter++;
      }
}

If all goes well, we should be able to pass this data and the appropriate map settings into the DrawMap method and get a map like the one below -- our points should line up perfectly with the intersecting latitude and longitude points.



This is a great starting point to build more functionality.  It would be fairly easy, for example, to add a little logic to "wrap" longitude in the event the left side of the map focused on Europe or Asia.  I'd also like to allow the specifying of a different reference point (or perhaps two points) since the algorithm above relies on the map width and location of 0,0 to calculate radius and coordinates.

As for finding great base maps, there's a lot of clip art sites out there (many of them free) that contain the appropriate Mercator-projected images.  My goal is to eventually remove the background in a report to see how the world stands out :)  Happy coding!
Comments are closed

My Apps

Dark Skies Astrophotography Journal Vol 1 Explore The Moon
Mars Explorer Moons of Jupiter Messier Object Explorer
Brew Finder Earthquake Explorer Venus Explorer  

My Worldmap

Month List