One of the more obvious missing features of the Map Kit framework is the ability to render heat maps on top of map views. However, while you’ve only met polygon overlays so far (see Chapter 3), it is also possible to use the overlay images on top of maps. You can use that to draw heat maps on top of your maps that will be fixed in the map view and move around as the user scrolls the map back and forth.
The demonstration application we’re going to build in this chapter
makes heavy use of heat map code written by George Polak at Skyhook
Wireless. Thanks to him and the all the team at Skyhook Wireless, both for
the code and assistance. The SHGeoUtils
class is copyright Skyhook Wireless
and used with permission; it is distributed under an MIT license.
You’re going to build a demonstration application with a heat map from data representing the Richter scale magnitude of a recent earthquake in New England.
Open Xcode and choose Create a new Xcode project in the startup
window. Choose the Single View Application template from the iOS section
of the New Project popup window. When prompted, name your new project
HeatMap
. In the Company Identifier
box, enter the root part of your Bundle Identifier (see the iOS
Provisioning Portal) used in your Provisioning Profile; for me, this is
uk.co.babilim. You should leave the Class Prefix box
blank. Ensure that the Device Family is set to iPhone, the checkbox for
ARC is ticked, and the boxes for storyboard and unit tests are not
ticked.
Save your project. When the Xcode project window opens, add the Map Kit and Core Location frameworks.
Click the project icon at the top of the Project pane in Xcode, click the main Target for the project, and then click the Build Phases tab. Finally, click the Link Binary with Libraries item to open up the list of linked frameworks, and click the + symbol to add a new framework. Select the MapKit.framework from the drop-down list and click the Add button to add the framework to your project. Then select the CoreLocation.framework from the list and similarly add it to your project.
Now that you’ve imported the Map Kit and Core Location frameworks, click the Supporting Files group in the Project navigator, and then click the Location_Prefix.pch file to open it in the standard editor. Instead of having to include the framework every time you need it, you can use this header file to import it into all the source files in the project, as highlighted in bold below:
#ifdef __OBJC__
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <CoreLocation/CoreLocation.h> #import <MapKit/MapKit.h>#endif
This file is prefixed to all of your source files. However, the compiler precompiles it separately; this means it does not have to reparse the file on each compile run, which can dramatically speed up your compile times on larger projects.
To keep things simple, you’re going to provide the earthquake data as a .plist file inside your application bundle and pass that to the code that will generate the overlay image. You can of course use your own data if you like, but the data I’m using here is available for download; if you can’t be bothered, you should just grab that.
You can download the earthquake data I’m using directly from the book’s website.
If you look inside the .plist file, you’ll see something that looks like this:
<?
xml
version
=
"1.0"
encoding
=
"UTF-8"
?>
<!
DOCTYPE
plist
PUBLIC
"-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd"
>
<
plist
version
=
"1.0"
>
<
array
>
<
dict
>
<
key
>
magnitude
</
key
>
<
real
>
3.4
</
real
>
<
key
>
latitude
</
key
>
<
real
>
42.7409
</
real
>
<
key
>
longitude
</
key
>
<
real
>-
71.4587
</
real
>
</
dict
>
<
dict
>
<
key
>
magnitude
</
key
>
<
real
>
2
</
real
>
<
key
>
latitude
</
key
>
<
real
>
41.7387
</
real
>
<
key
>
longitude
</
key
>
<
real
>-
72.6697
</
real
>
</
dict
>
<
dict
>
<
key
>
magnitude
</
key
>
<
real
>
2
</
real
>
<
key
>
latitude
</
key
>
<
real
>
41.3384
</
real
>
<
key
>
longitude
</
key
>
<
real
>-
72.6265
</
real
>
</
dict
>
.
.
.
</
array
>
</
plist
>
If you haven’t come across one before, a .plist file is often used to store a user’s settings, but it’s also used to store information and configuration data for applications, as you’re doing here.
In any case, drag and drop the data file into your project into the Supporting Files group in the Project Navigator. If you click it afterwards, you should see something like Figure 6-1.
Now click the ViewController.xib nib file to open the
Interface Builder. Open up the Utility panel and drag and drop a Map
View (MKMapView
) from the Object
Library into your view. Then close the Utility panel, open the Assistant
editor, and right-click and drag from the map view to your header file
to connect the view to your code as an IBOutlet
(see Figure 6-2).
Then right-click and drag from the Map View to the App Delegate icon at the top of the dock to make the View Controller a delegate of our new Map View (see Figure 6-3).
Once you’ve done that, close the Assistant editor and reopen the
Standard editor, and click the ViewController.h interface file. Declare the
View Controller as an MKMapViewDelegate
as part of the interface
declaration:
#import <UIKit/UIKit.h>
@interface
ViewController
:UIViewController
<
MKMapViewDelegate
>
@property
(
weak
,
nonatomic
)
IBOutlet
MKMapView
*
mapView
;
@end
By clicking the corresponding implementation file, you need to
initialize your map view in the viewDidLoad:
method:
-
(
void
)
viewDidLoad
{
[
super
viewDidLoad
];
MKCoordinateSpan span = MKCoordinateSpanMake(10.0, 13.0); CLLocationCoordinate2D center = CLLocationCoordinate2DMake(39.0, −77.0); MKCoordinateRegion region = MKCoordinateRegionMake(center, span); self.mapView.region = region;}
Once you save your changes and click the Run button in the Xcode toolbar, you’ll see that the map will be centered somewhere around Washington D.C. (see Figure 6-4).
At this point, similar to the polygon-style overlays you saw in
Chapter 3, you need to create an object that implements
the MKOverlay
protocol and a
corresponding MKOverlayView
object.
However, since your image overlays are going to be slightly more
involved than the polygons you used last time, you’re going to have to
roll your own implementations of these classes.
Right-click on the HeatMap group in the Project Navigator, select
New file from the menu, choose an Objective-C class, and make the new
class a subclass of NSObject
and
class it MapOverlay
when
prompted.
Do this again, but this time you should create your new class as a
subclass of the MKOverlayView
class
rather than NSObject
. Name this class
MapOverlayView
when prompted to
do so.
You should end up with four new files; MapOverlay.h, MapOverlay.m, MapOverlayView.h, and MapOverlayView.m.
Click the MapOverlay.h
interface file to open it in the Standard editor, declare that it
implements the MKOverlay
protocol,
and change it as follows:
#import <Foundation/Foundation.h>
@interface
MapOverlay
:NSObject
<
MKOverlay
>
{
CLLocationCoordinate2D
_coordinate
;
MKMapRect
_mapRect
;
MKMapView
*
_mapView
;
}
-
(
id
)
initWithView:
(
MKMapView
*
)
mapView
;
-
(
MKMapRect
)
boundingMapRect
;
@property
(
nonatomic
,
readonly
)
CLLocationCoordinate2D
coordinate
;
@property
(
nonatomic
,
readonly
)
MKMapRect
mapRect
;
@property
(
nonatomic
,
strong
)
MKMapView
*
mapView
;
@end
Change it in the corresponding MapOverlay.m implementation file as well:
#import "MapOverlay.h"
@implementation
MapOverlay
@synthesize
mapView
=
_mapView
;
-
(
id
)
initWithView:
(
MKMapView
*
)
mapView
{
if
(
self
=
[
super
init
])
{
self
.
mapView
=
mapView
;
_coordinate
=
mapView
.
centerCoordinate
;
_mapRect
=
mapView
.
visibleMapRect
;
}
return
self
;
}
-
(
CLLocationCoordinate2D
)
coordinate
{
return
_coordinate
;
}
-
(
MKMapRect
)
boundingMapRect
{
return
_mapRect
;
}
@end
You can see that you’re going to pass the Map View you want to put the overlay on top of to your overlay class, and use that map view to configure the center and boundaries of your overlay.
You should notice that you’re extracting the center and boundaries of the Map View when you initially create the overlay. If you do not do this, then the resulting overlay will not scale correctly as the map view is zoomed in and out.
Essentially you’re going to overlap an image over your entire Map View, or at least the part that is initially visible in the Window when your application starts up.
You can leave the MapOverlayView.h interface file as it is—you
don’t need to make any changes there. Instead, open the MapOverlayView.m implementation file and add
the required drawMapRect:zoomScale:inContext:
method.
#import "MapOverlayView.h"
#import "MapOverlay.h"
@implementation
MapOverlayView
- (void)drawMapRect:(MKMapRect)mapRect zoomScale:(MKZoomScale)zoomScale inContext:(CGContextRef)ctx { // Load in earthquake data NSString *dataFile = [[NSBundle mainBundle] pathForResource:@"EarthQuake-Data" ofType:@"plist"]; NSArray *quakeData = [[NSArray alloc] initWithContentsOfFile:dataFile]; NSMutableArray *locations = [[NSMutableArray alloc] initWithCapacity:[quakeData count]]; NSMutableArray *weights = [[NSMutableArray alloc] initWithCapacity:[quakeData count]]; for (NSDictionary *reading in quakeData) { CLLocationDegrees latitude = [[reading objectForKey:@"latitude"] doubleValue]; CLLocationDegrees longitude = [[reading objectForKey:@"longitude"] doubleValue]; double magnitude = [[reading objectForKey:@"magnitude"] doubleValue]; CLLocation *location = [[CLLocation alloc] initWithLatitude:latitude longitude:longitude]; [locations addObject:location]; [weights addObject:[NSNumber numberWithInteger:(magnitude * 10)]]; } //Loading and set up the image overlay MKMapRect theMapRect = [self.overlay boundingMapRect]; CGRect theCGRect = [self rectForMapRect:theMapRect]; MapOverlay *overlay = (MapOverlay *)self.overlay; MKMapView *view = overlay.mapView; UIImage *heatmap = nil;CGImageRef imageRef = heatmap.CGImage; // Flip and reposition the image CGContextScaleCTM(ctx, 1.0, −1.0); CGContextTranslateCTM(ctx, 0.0, -theCGRect.size.height); //drawing the image to the context CGContextDrawImage(ctx, theCGRect, imageRef); }
// ... insert code here to generate the heatmap ...
@end
Go back to the ViewController.m implementation file and import both your new classes:
#import "MapOverlay.h"
#import "MapOverlayView.h"
Then do the same in the viewDidLoad:
method:
-
(
void
)
viewDidLoad
{
[
super
viewDidLoad
];
MKCoordinateSpan
span
=
MKCoordinateSpanMake
(
10.0
,
13.0
);
CLLocationCoordinate2D
center
=
CLLocationCoordinate2DMake
(
39.0
,
−
77.0
);
MKCoordinateRegion
region
=
MKCoordinateRegionMake
(
center
,
span
);
self
.
mapView
.
region
=
region
;
MapOverlay * mapOverlay = [[MapOverlay alloc] initWithView:self.mapView]; [self.mapView addOverlay:mapOverlay];}
Create your overlay. Finally, you need to add the delegate callback to the same class:
-
(
MKOverlayView
*
)
mapView:
(
MKMapView
*
)
mapView
viewForOverlay:
(
id
<
MKOverlay
>
)
overlay
{
MapOverlay
*
mapOverlay
=
(
MapOverlay
*
)
overlay
;
MapOverlayView
*
mapOverlayView
=
[[
MapOverlayView
alloc
]
initWithOverlay:
mapOverlay
];
return
mapOverlayView
;
}
If you save your changes and click the Run button in the Xcode
toolbar, your application should successfully build and deploy into the
iPhone Simulator. Of course, this happens right at the moment the
overlaid image is set to nil
, so the
application will look exactly the same as it did in Figure 6-4, but you should be
able to check that everything builds and runs correctly.
Let’s fix that right now. To do that, you’re going to make use of
the SHGeoUtils
class, written by
George Polak of Skyhook Wireless.
You can download the SHGeoUtils
class files from the book’s
website at:
http://programmingiphonesensors.com/code/SHGeoUtils.h
http://programmingiphonesensors.com/code/SHGeoUtils.m
The code is released under an MIT license.
Looking at the SHGeoUtils
class, you can see that it provides three class methods. All of them
will return a UIImage
that can then
be used by your MapOverlayView
class.
#import <Foundation/Foundation.h>
@interface
SHGeoUtils
:NSObject
+
(
UIImage
*
)
heatMapForMapView:
(
MKMapView
*
)
mapView
boost:
(
float
)
boost
locations:
(
NSArray
*
)
locations
weights:
(
NSArray
*
)
weights
;
+
(
UIImage
*
)
heatMapWithRect:
(
CGRect
)
rect
boost:
(
float
)
boost
points:
(
NSArray
*
)
points
weights:
(
NSArray
*
)
weights
;
+
(
UIImage
*
)
heatMapWithRect:
(
CGRect
)
rect
boost:
(
float
)
boost
points:
(
NSArray
*
)
points
weights:
(
NSArray
*
)
weights
weightsAdjustmentEnabled:
(
BOOL
)
weightsAdjustmentEnabled
groupingEnabled:
(
BOOL
)
groupingEnabled
;
@end
This generates a heat map image for the specified map view.
You pass the locations as an NSArray
of CLLocation
objects and the weights as an
NSArray
of NSNumber
objects corresponding to the
weighting of each point. There should be a one-to-one correspondence
between the location and weight elements. Passing a nil
weight parameter implies an even
weight distribution between the location points. The boost value
represents a radius multiplier: values close to 1.0 will produce
very diffuse maps, while values close to 0.0 will produce maps that
are pointlike in nature.
Instead of specifiying a map view, you are asked to specifiy a
CGRect
region frame for the view
and your locations as an NSArray
of NSValue CGPoint
objects
representing the data points.
The final method allows you to adjust the weighting of the
points you pass to the heat map code: setting YES
allows weight balancing and
normalization to occur. You can also enable grouping: setting
YES
allows for tighter visual
grouping of dense areas of data.
Download the utility classes and drag and drop them into your project.
You’ve set up your overlay view so that you can easily use the
first—and most convenient—of the three methods provided by the SHGeoUtils
class. Click the MapOverlayView.m implementation file to open
it in the editor, and in the drawMapRect:zoomScale:inContext:
method, make
the following change:
-
(
void
)
drawMapRect:
(
MKMapRect
)
mapRect
zoomScale:
(
MKZoomScale
)
zoomScale
inContext:
(
CGContextRef
)
ctx
{
// Load in earthquake data
NSString
*
dataFile
=
[[
NSBundle
mainBundle
]
pathForResource:
@"EarthQuake-Data"
ofType:
@"plist"
];
NSArray
*
quakeData
=
[[
NSArray
alloc
]
initWithContentsOfFile:
dataFile
];
NSMutableArray
*
locations
=
[[
NSMutableArray
alloc
]
initWithCapacity:
[
quakeData
count
]];
NSMutableArray
*
weights
=
[[
NSMutableArray
alloc
]
initWithCapacity:
[
quakeData
count
]];
for
(
NSDictionary
*
reading
in
quakeData
)
{
CLLocationDegrees
latitude
=
[[
reading
objectForKey:
@"latitude"
]
doubleValue
];
CLLocationDegrees
longitude
=
[[
reading
objectForKey:
@"longitude"
]
doubleValue
];
double
magnitude
=
[[
reading
objectForKey:
@"magnitude"
]
doubleValue
];
CLLocation
*
location
=
[[
CLLocation
alloc
]
initWithLatitude:
latitude
longitude:
longitude
];
[
locations
addObject:
location
];
[
weights
addObject:
[
NSNumber
numberWithInteger:
(
magnitude
*
10
)]];
}
//Loading and set up the image overlay
MKMapRect
theMapRect
=
[
self
.
overlay
boundingMapRect
];
CGRect
theCGRect
=
[
self
rectForMapRect:
theMapRect
];
MapOverlay
*
overlay
=
(
MapOverlay
*
)
self
.
overlay
;
MKMapView
*
view
=
overlay
.
mapView
;
UIImage *heatmap = [SHGeoUtils heatMapForMapView:view boost:0.67 locations:locations weights:weights];CGImageRef
imageRef
=
heatmap
.
CGImage
;
// Flip and reposition the image
CGContextScaleCTM
(
ctx
,
1.0
,
−
1.0
);
CGContextTranslateCTM
(
ctx
,
0.0
,
-
theCGRect
.
size
.
height
);
//drawing the image to the context
CGContextDrawImage
(
ctx
,
theCGRect
,
imageRef
);
}
Save your changes and click the Run button in the Xcode editor. If all goes well, you should see something like Figure 6-5.
At this point, you should be able to pan the map as normal, rotate the interface into landscape mode and back, and pinch and zoom. The heat map should move and scale, just as you’d expect.
At this point, you can generalize your MapOverlay
and MapOverlayView
classes somewhat to cope with
arbitrary data. For instance, you can modify your MapOverlay
class to take both the locations
and weights during initialization rather than reading these in inside
your MapOverlayView
class. Changing
the MapOverlay.h interface file as
below:
#import <Foundation/Foundation.h>
#import <MapKit/MapKit.h>
@interface
MapOverlay
:NSObject
<
MKOverlay
>
{
CLLocationCoordinate2D
_coordinate
;
MKMapRect
_mapRect
;
MKMapView
*
_mapView
;
NSArray
*
_locations
;
NSArray
*
_weights
;
}
-
(
MKMapRect
)
boundingMapRect
;
-
(
id
)
initWithView:
(
MKMapView
*
)
mapView
andLocations:
(
NSArray
*
)
locations
andWeights:
(
NSArray
*
)
weights
;
@property
(
nonatomic
,
readonly
)
CLLocationCoordinate2D
coordinate
;
@property
(
nonatomic
,
readonly
)
MKMapRect
mapRect
;
@property
(
nonatomic
,
strong
)
MKMapView
*
mapView
;
@property
(
nonatomic
,
strong
)
NSArray
*
locations
;
@property
(
nonatomic
,
strong
)
NSArray
*
weights
;
@end
along with the MapOverlay.m implementation
#import "MapOverlay.h"
@implementation
MapOverlay
@synthesize
mapView
=
_mapView
;
@synthesize
locations
=
_locations
;
@synthesize
weights
=
_weights
;
-
(
id
)
initWithView:
(
MKMapView
*
)
mapView
andLocations:
(
NSArray
*
)
locations
andWeights:
(
NSArray
*
)
weights
{
if
(
self
=
[
super
init
])
{
self
.
mapView
=
mapView
;
self
.
locations
=
locations
;
self
.
weights
=
weights
;
_coordinate
=
mapView
.
centerCoordinate
;
_mapRect
=
mapView
.
visibleMapRect
;
}
return
self
;
}
-
(
CLLocationCoordinate2D
)
coordinate
{
return
_coordinate
;
}
-
(
MKMapRect
)
boundingMapRect
{
return
_mapRect
;
}
@end
allows us to modify the MapOverlayView.m implementation.
#import "MapOverlayView.h"
#import "MapOverlay.h"
#import "SHGeoUtils.h"
@implementation
MapOverlayView
-
(
void
)
drawMapRect:
(
MKMapRect
)
mapRect
zoomScale:
(
MKZoomScale
)
zoomScale
inContext:
(
CGContextRef
)
ctx
{
//Loading and setting the image
MKMapRect
theMapRect
=
[
self
.
overlay
boundingMapRect
];
CGRect
theCGRect
=
[
self
rectForMapRect:
theMapRect
];
MapOverlay
*
overlay
=
(
MapOverlay
*
)
self
.
overlay
;
MKMapView
*
view
=
overlay
.
mapView
;
UIImage
*
heatmap
=
[
SHGeoUtils
heatMapForMapView:
view
boost:
0.67
locations:
overlay
.
locations
weights:
overlay
.
weights
];
CGImageRef
imageRef
=
heatmap
.
CGImage
;
// We need to flip and reposition the image here
CGContextScaleCTM
(
ctx
,
1.0
,
−
1.0
);
CGContextTranslateCTM
(
ctx
,
0
,
-
theCGRect
.
size
.
height
);
//drawing the image to the context
CGContextDrawImage
(
ctx
,
theCGRect
,
imageRef
);
}
@end
You might have noticed that your call to the heat map code hooks
into your overlay code in just one place, so your code can be easily
modified to display any image. If you drag and drop an image of an
American flag into your project and edit the drawMapRect:zoomScale:inContext:
method in the
MapOverlayView
implementation to pull
that image instead of your earthquake data heat map, you get something
like Figure 6-6 when you
build and run the new code.
For example:
-
(
void
)
drawMapRect:
(
MKMapRect
)
mapRect
zoomScale:
(
MKZoomScale
)
zoomScale
inContext:
(
CGContextRef
)
ctx
{
//Loading and setup the image
MKMapRect
theMapRect
=
[
self
.
overlay
boundingMapRect
];
CGRect
theCGRect
=
[
self
rectForMapRect:
theMapRect
];
UIImage
*
image
=
[
UIImage
imageNamed:
@"Flag.png"
];
CGImageRef
imageRef
=
heatmap
.
CGImage
;
// We need to flip and reposition the image here
CGContextScaleCTM
(
ctx
,
1.0
,
−
1.0
);
CGContextTranslateCTM
(
ctx
,
0.0
,
-
theCGRect
.
size
.
height
);
//drawing the image to the context
CGContextDrawImage
(
ctx
,
theCGRect
,
imageRef
);
}
The size of the flag denotes the size of the extent of the original map view as your current overlay code scales the size of the overlaid object to be the size of the existing map view on startup.