Monday, January 10, 2011

iPhone / iPad MapView without Google


One of the challenges I recently faced was the ability to utilise third-party mapping tiles and potentially cache them locally on the iPhone/iPad.

The standard Google MapView and delegate classes supplied by Apple's SDK does not easily allow this so I went searching for another solution. That is when I came across an Open Source framework called route-me.

I already had a standard web solution called myTRC that utilised GeoServer and Geomajas. So ideally I wanted to make use of this existing service by requesting tiles via WMS (Web Map Service).

After I bit of experimenting and researching I was to create a simple iPhone/iPad application that requested and cached WMS tiles.

The only modification I had to make to GeoSever was to create a new layer group and change the projection to EPSG:900913 to avoid the re-projection of the iOS Location Service. I suspect this re-projection requires further investigation as the location service to map is slightly out.

The changes to route-me required two new files called RMGenericMercatorWMSSource.h and RMGenericMercatorWMSSource.m. The following thread will also help you understand its background and issues. See the end of this blog to obtain these two classes.

Note: The following instructions assumes basic knowledge of iPhone development and troubleshooting.

To get started copy the example call 'SimpleMap' supplied by route-me.

1. Add the RMGenericMercatorWMSSource.h and RMGenericMercatorWMSSource.m classes.

2. Modify the initialisation of the MapView to use WMS.

Example.
[mapView setDelegate:self];
 
 NSDictionary *wmsParameters = 
 [[NSDictionary alloc] initWithObjects:
   [NSArray arrayWithObjects:@"EPSG:900913",@"",@"TRCImagery",nil]
          forKeys:[NSArray arrayWithObjects:@"SRS",@"styles",@"layers",nil]];
 
 id myTilesource = [[[RMGenericMercatorWMSSource alloc]   
  initWithBaseUrl:@"http://yourURL.com/wms?"   
  parameters:wmsParameters] autorelease]; 


My full source for MapViewViewController.m
#import "MapViewViewController.h"

#import "RMMapContents.h"
#import "RMFoundation.h"
#import "RMMarker.h"
#import "RMMarkerManager.h"
#import "RMGenericMercatorWMSSource.h"
#import "SlidingMessageViewController.h"

@implementation MapViewViewController

@synthesize mapView;
@synthesize locationManager;
@synthesize currentLocation;
@synthesize infoTextView;
@synthesize slidingInfo;

- (void)dealloc 
{
    [mapView release];
    [locationManager stopUpdatingLocation];
    [locationManager release];    
    [slidingInfo release];
    [super dealloc];
}

// Override initWithNibName:bundle: to load the view using a nib file then perform additional customization that is not appropriate for viewDidLoad.
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
    if (self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) 
    {
        // customisation
    }
    
    return self;
}

- (BOOL)mapView:(RMMapView *)map 
shouldDragMarker:(RMMarker *)marker
      withEvent:(UIEvent *)event
{
    //If you do not implement this function, then all drags on markers will be sent to the didDragMarker function.
    //If you always return YES you will get the same result
    //If you always return NO you will never get a call to the didDragMarker function
    return YES;
}

- (void)mapView:(RMMapView *)map
  didDragMarker:(RMMarker *)marker 
      withEvent:(UIEvent *)event 
{
    CGPoint position = [[[event allTouches] anyObject] locationInView:mapView];
    
    RMMarkerManager *markerManager = [mapView markerManager];
    
    NSLog(@"New location: east:%lf north:%lf", [marker projectedLocation].easting, [marker projectedLocation].northing);
    CGRect rect = [marker bounds];
    
    [markerManager moveMarker:marker 
                         AtXY:CGPointMake(position.x,position.y +rect.size.height/3)];
    
}

- (void)updateInfo {
    RMMapContents *contents = self.mapView.contents;
    CLLocationCoordinate2D mapCenter = [contents mapCenter];
    
    double truescaleDenominator = [contents scaleDenominator];
    double routemeMetersPerPixel = [contents metersPerPixel]; 
    [infoTextView setText:[NSString stringWithFormat:@"Latitude : %f\nLongitude : %f\nZoom: %.2f Meter per pixel : %.1f\nTrue scale : 1:%.0f", 
                           mapCenter.latitude, 
                           mapCenter.longitude, 
                           contents.zoom, 
                           routemeMetersPerPixel,
                           truescaleDenominator]];
}


- (void)initLocationServices {
    locationManager    = [[CLLocationManager alloc] init];
    locationManager.delegate        = self;
    locationManager.desiredAccuracy = kCLLocationAccuracyBest;
}

- (void) startLocationServices {
    NSLog(@"Starting location service");
    [locationManager startUpdatingLocation];    
}

- (void) stopLocationServices {
    [locationManager stopUpdatingLocation];
}

- (void) initNotifications {
    //Notifications for tile requests.  This code allows for a class to 
    //know when a tile is requested and retrieved
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(tileRequested:)
                                                 name:@"RMTileRequested" object:nil ];
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(tileRetrieved:)
                                                 name:@"RMTileRetrieved" object:nil ];    
}

- (void) initSpinner {
    pendingTiles = 0;    
    spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
    [spinner setCenter: self.view.center]; 
    [self.mapView addSubview:spinner]; 
}

- (void)viewDidLoad 
{
    NSLog(@"viewDidLoad");
    
    [super viewDidLoad];
    
    [self initLocationServices];
    [self startLocationServices];
    
    self.slidingInfo = [[[SlidingMessageViewController alloc] initWithView:infoTextView] autorelease];
    
    //-17.334991+145.578552
    
    currentLocation.latitude = -17.334991;
    currentLocation.longitude = 145.578552;
    
    
    tap = NO;
    
    [mapView setDelegate:self];
    
    NSDictionary *wmsParameters = 
    [[NSDictionary alloc] initWithObjects:
     [NSArray arrayWithObjects:@"EPSG:900913",@"",@"TRCImagery",nil] 
                                  forKeys:[NSArray arrayWithObjects:@"SRS",@"styles",@"layers",nil]];
    
    id myTilesource = [[[RMGenericMercatorWMSSource alloc]   
                        initWithBaseUrl:@"http://yoururl.com/wms?"   
                        parameters:wmsParameters] autorelease]; 
    [[[RMMapContents alloc] initWithView:mapView 
                              tilesource:myTilesource 
                            centerLatLon:currentLocation 
                               zoomLevel:12.0f 
                            maxZoomLevel:18.0f 
                            minZoomLevel:9.0f 
                         backgroundImage:nil] autorelease]; 
    
    
    [mapView setBackgroundColor:[UIColor grayColor]];  //or clear etc 
    
    if (locationManager.location != nil)
    {
        currentLocation = locationManager.location.coordinate;
        
        NSLog(@"Location: Lat: %lf Lon: %lf", currentLocation.latitude, currentLocation.longitude);
    }
    
    [mapView moveToLatLong:currentLocation]; 
    [self.view addSubview:mapView]; 
    
    [self updateInfo];
    
    RMMarkerManager *markerManager = [mapView markerManager];
    
    RMMarker *marker = [[RMMarker alloc] initWithUIImage: [UIImage imageNamed:@"74-location.png"]];
    [marker setTextForegroundColor:[UIColor blueColor]];
    [markerManager addMarker:marker AtLatLong:currentLocation];
    [marker release];
    
    [self initNotifications];
    [self initSpinner];
    
}

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation 
{
    return YES;
}


- (void)didReceiveMemoryWarning 
{
    // due to a bug, RMMapView should never be released, as it causes the application to crash
    //[super didReceiveMemoryWarning]; // Releases the view if it doesn't have a superview
    
    [mapView.contents didReceiveMemoryWarning];
}



#pragma mark --
#pragma mark locationManagerDelegate Methods

- (void)locationManager: (CLLocationManager *)manager 
    didUpdateToLocation: (CLLocation *)newLocation
           fromLocation: (CLLocation *)oldLocation
{
    
    if ([newLocation.timestamp timeIntervalSinceNow] > 10.0) {
        NSLog(@"The position is too recent" );
        return;
    }
    
    NSLog(@"Moving from lat: %lf lon: %lf to lat: %lf lon: %lf accuracy: %f,%df", 
          oldLocation.coordinate.latitude, oldLocation.coordinate.longitude,
          newLocation.coordinate.latitude, newLocation.coordinate.longitude,
          newLocation.horizontalAccuracy, newLocation.verticalAccuracy);
    
    currentLocation = newLocation.coordinate;
    RMMarkerManager *markerManager = [mapView markerManager];
    NSArray *markers = [markerManager markers];
    for (NSInteger i = 0; i < [markers count]; ++i)
    {
        RMMarker *marker = [markers objectAtIndex: i];
        CLLocationCoordinate2D location = [markerManager latitudeLongitudeForMarker:marker];
        if (location.latitude == oldLocation.coordinate.latitude &&
            location.longitude == oldLocation.coordinate.longitude)
        {
            [markerManager moveMarker: marker
                             AtLatLon: newLocation.coordinate];
            break; // We're done. 
        }
    }
    
    [mapView moveToLatLong:currentLocation]; 
}

- (void)locationManager: (CLLocationManager *)manager
       didFailWithError: (NSError *)error
{
    NSLog(@"Location Manager error: %@", [error localizedDescription]);
}

#pragma mark -
#pragma mark Delegate methods

- (void) afterMapMove: (RMMapView*) map {
    [self updateInfo];
    [self stopLocationServices];
}

- (void) afterMapZoom: (RMMapView*) map byFactor: (float) zoomFactor near:(CGPoint) center {
    [self updateInfo];
}

- (IBAction) toggleInfo: (id) sender {
    [self.slidingInfo toggle];
}

- (void) clearCache {
    NSLog(@"Clearing cache");
    [mapView.contents removeAllCachedImages];
    [[NSURLCache sharedURLCache] removeAllCachedResponses];
}

- (IBAction) promptClearCache: (id) sender {
    UIAlertView *alertWithYesNoButtons = [[UIAlertView alloc] initWithTitle:@"Clear Off-line Cache"
                                                                    message:@"Clear the Off-line Map Cache?" delegate:self cancelButtonTitle:@"No" otherButtonTitles:@"Yes", nil];
    [alertWithYesNoButtons setTag: 1];
    [alertWithYesNoButtons show];
    [alertWithYesNoButtons release];
}

- (IBAction) locateMe: (id) sender {
    [self startLocationServices];
}

- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
    NSString *buttonTitle = [alertView buttonTitleAtIndex:buttonIndex];
    
    if ([alertView tag] == 1) {
        if ([buttonTitle isEqualToString:@"Yes"]) {
            [self clearCache];
        }
    }
}

-(void)tileRequested:(NSNotification *)notification
{
    if (pendingTiles == 0) {
        [spinner startAnimating];
    }
    pendingTiles++;
    NSLog(@"Tile request started - %d", pendingTiles);
}

-(void)tileRetrieved:(NSNotification *)notification;
{
    pendingTiles--;
    if (pendingTiles == 0) {
        [spinner stopAnimating];
    }
    NSLog(@"Tile request ended - %d", pendingTiles);
}

@synthesize tap;
@synthesize tapCount;
@end
RMGenericMercatorWMSSource.h
#import 
#import 
#import 
#import "RMLatLong.h"
#import "RMAbstractMercatorWebSource.h"

typedef struct { 
 CGPoint ul; 
 CGPoint lr; 
} CGXYRect; 

@interface RMGenericMercatorWMSSource : RMAbstractMercatorWebSource  {
 NSMutableDictionary *wmsParameters;
 NSString *urlTemplate;
 CGFloat initialResolution, originShift;
}

- (RMGenericMercatorWMSSource *)initWithBaseUrl:(NSString *)baseUrl parameters:(NSDictionary *)params;

- (CGPoint)LatLonToMeters:(CLLocationCoordinate2D)latlon;
- (float)ResolutionAtZoom:(int)zoom;
- (CGPoint)PixelsToMeters:(int)px PixelY:(int)py atZoom:(int)zoom;
- (CLLocationCoordinate2D)MetersToLatLon:(CGPoint)meters;
- (CGXYRect)TileBounds:(RMTile)tile;

@end
RMGenericMercatorWMSSource.m
#import "RMGenericMercatorWMSSource.h"

CGFloat DegreesToRadians(CGFloat degrees) {return degrees * M_PI / 180;}; 
CGFloat RadiansToDegrees(CGFloat radians) {return radians * 180/ M_PI;}; 

@implementation RMGenericMercatorWMSSource

-(id) initWithBaseUrl:(NSString *)baseUrl parameters:(NSDictionary *)params
{ 
 if (![super init]) 
  return nil; 
 initialResolution = 2 * M_PI * 6378137 / [self tileSideLength];
 // 156543.03392804062 for sideLength 256 pixels 
 originShift = 2 * M_PI * 6378137 / 2.0;
 // 20037508.342789244 
 
 // setup default parameters
 // use official EPSG:3857 by default, user can override to 900913 if needed.
 wmsParameters = [[NSMutableDictionary alloc] initWithObjects:[[[NSArray alloc] 
      initWithObjects:@"EPSG:900913",@"image/png",@"GetMap",@"1.1.1",@"WMS",nil] autorelease] 
      forKeys:[[[NSArray alloc] initWithObjects:@"SRS",@"FORMAT",@"REQUEST",@"VERSION",@"SERVICE",nil] autorelease]];
 [wmsParameters addEntriesFromDictionary:params];

 // build WMS request URL template
 urlTemplate = [NSString stringWithString:baseUrl];
 NSEnumerator *e = [wmsParameters keyEnumerator];
 NSString *key;
 NSString *delimiter = @"";
 while (key = [e nextObject]) {
  urlTemplate = [urlTemplate stringByAppendingFormat:@"%@%@=%@",
        delimiter,
        [[key uppercaseString] stringByAddingPercentEscapesUsingEncoding:NSASCIIStringEncoding], 
        [[wmsParameters objectForKey:key] stringByAddingPercentEscapesUsingEncoding:NSASCIIStringEncoding]];
  delimiter = @"&";
 }
 int sideLength =  [self tileSideLength];
 urlTemplate = [[urlTemplate stringByAppendingFormat:@"&WIDTH=%d&HEIGHT=%d",sideLength,sideLength] retain];
 return self;
}


-(NSString*) tileURL: (RMTile) tile 
{ 
 //RMLatLongBounds tileBounds = [self TileLatLonBounds:tile];
 // Get BBOX coordinates in meters
 CGXYRect tileBounds = [self TileBounds:tile];
 
 NSString *url = [urlTemplate stringByAppendingFormat:@"&BBOX=%f,%f,%f,%f",
      tileBounds.ul.x,
      tileBounds.lr.y,
      tileBounds.lr.x,
      tileBounds.ul.y];
 NSLog(@"Tile %d,%d,%d yields %@",tile.zoom, tile.x, tile.y, url); 
 return url; 
} 

// implement in subclass?
-(NSString*) uniqueTilecacheKey
{
 return @"AbstractMercatorWMSSource";
}

-(NSString *)shortName
{
 return @"Generic WMS Source";
}
-(NSString *)longDescription
{
 return @"Generic WMS Source";
}
-(NSString *)shortAttribution
{
 return @"Generic WMS Source";
}
-(NSString *)longAttribution
{
 return @"Generic WMS Source";
}

-(float) minZoom
{
 return 9.0f;
}
-(float) maxZoom
{
 return 18.0f;
}

// Converts given lat/lon in WGS84 Datum to XY in Spherical Mercator EPSG:3857 
-(CGPoint) LatLonToMeters: (CLLocationCoordinate2D) latlon 
{ 
 CGPoint meters; 
 meters.x = latlon.longitude * originShift / 180.0; 
 meters.y = (log( tan((90.0 + latlon.latitude) * M_PI / 360.0 )) / (M_PI / 180.0)) * originShift / 180.0; 
 return meters; 
}

//Converts XY point from Spherical Mercator EPSG:3857 to lat/lon in WGS84 Datum 
-(CLLocationCoordinate2D) MetersToLatLon: (CGPoint) meters 
{ 
 CLLocationCoordinate2D latlon; 
 latlon.longitude = (meters.x / originShift) * 180.0; 
 latlon.latitude = (meters.y / originShift) * 180.0; 
 //latlon.latitude = - 180 / M_PI * (2 * atan( exp( latlon.latitude * M_PI / 180.0)) - M_PI / 2.0); 
 latlon.latitude = 180 / M_PI * (2 * atan( exp( latlon.latitude * M_PI / 180.0)) - M_PI / 2.0); 
 return latlon; 
} 

// Converts pixel coordinates in given zoom level of pyramid to EPSG:3857 
-(CGPoint) PixelsToMeters: (int) px PixelY:(int)py atZoom:(int)zoom 
{ 
 float resolution = [self ResolutionAtZoom: zoom]; 
 CGPoint meters; 
 meters.x = px * resolution - originShift; 
 meters.y = py * resolution - originShift; 
 return meters; 
} 

//Returns bounds of the given tile in EPSG:3857 coordinates 
-(CGXYRect)  TileBounds: (RMTile) tile 
{
 int sideLength =  [self tileSideLength];

 int zoom = tile.zoom;
 long twoToZoom = pow(2,zoom);
 CGXYRect tileBounds; 
 tileBounds.ul = [self PixelsToMeters: (tile.x * sideLength) 
          PixelY: ((twoToZoom-tile.y) * sideLength) 
          atZoom: zoom ]; 
 tileBounds.lr = [self PixelsToMeters: ((tile.x+1) * sideLength) 
          PixelY: ((twoToZoom-tile.y-1) * sideLength) 
          atZoom: zoom];
 return tileBounds; 
} 

//Resolution (meters/pixel) for given zoom level (measured at Equator) 
-(float) ResolutionAtZoom : (int) zoom 
{ 
 return initialResolution / pow(2,zoom); 
} 

@end