12

I am working on an app that uses MKOverlay views to layer my own custom maps on top of the Google base map. I have been using Apple's excellent TileMap sample code (from WWDC 2010) as a guide.

My problem - when "overzoomed" to a level of detail deeper than my generated tile set, the code displays nothing because there are no tiles available at the calculated Z level.

The behavior I want - when "overzoomed" the app should just keep magnifying the deepest level of tiles. It is a good user experience for the overlay to become blurrier - it is a very bad experience to have the overlay vanish.

Here is the code which returns the tiles to draw - I need to figure out how to modify this to cap the Z-depth without breaking the scaling of the frame being calculated for the overlay tile. Any thoughts???


- (NSArray *)tilesInMapRect:(MKMapRect)rect zoomScale:(MKZoomScale)scale
{
    NSInteger z = zoomScaleToZoomLevel(scale);

    // PROBLEM: I need to find a way to cap z at my maximum tile directory depth.

    // Number of tiles wide or high (but not wide * high)
    NSInteger tilesAtZ = pow(2, z);

    NSInteger minX = floor((MKMapRectGetMinX(rect) * scale) / TILE_SIZE);
    NSInteger maxX = floor((MKMapRectGetMaxX(rect) * scale) / TILE_SIZE);
    NSInteger minY = floor((MKMapRectGetMinY(rect) * scale) / TILE_SIZE);
    NSInteger maxY = floor((MKMapRectGetMaxY(rect) * scale) / TILE_SIZE);

    NSMutableArray *tiles = nil;

    for (NSInteger x = minX; x <= maxX; x++) {
        for (NSInteger y = minY; y <= maxY; y++) {
            // As in initWithTilePath, need to flip y index
            // to match the gdal2tiles.py convention.
            NSInteger flippedY = abs(y + 1 - tilesAtZ);

            NSString *tileKey = [[NSString alloc] 
                                  initWithFormat:@"%d/%d/%d", z, x, flippedY];
            if ([tilePaths containsObject:tileKey]) {
                if (!tiles) {
                    tiles = [NSMutableArray array];
                }

                MKMapRect frame = MKMapRectMake((double)(x * TILE_SIZE) / scale,
                                                (double)(y * TILE_SIZE) / scale,
                                                TILE_SIZE / scale,
                                                TILE_SIZE / scale);

                NSString *path = [[NSString alloc] initWithFormat:@"%@/%@.png",
                      tileBase, tileKey];
                ImageTile *tile = [[ImageTile alloc] initWithFrame:frame path:path];
                [path release];
                [tiles addObject:tile];
                [tile release];
            }
            [tileKey release];
        }
    }

    return tiles;
}

FYI, here is the zoomScaleToZoomLevel helper function that someone asked about:

// Convert an MKZoomScale to a zoom level where level 0 contains 4 256px square tiles,
// which is the convention used by gdal2tiles.py.
static NSInteger zoomScaleToZoomLevel(MKZoomScale scale) {
    double numTilesAt1_0 = MKMapSizeWorld.width / TILE_SIZE;
    NSInteger zoomLevelAt1_0 = log2(numTilesAt1_0);  // add 1 because the convention skips a virtual level with 1 tile.
    NSInteger zoomLevel = MAX(0, zoomLevelAt1_0 + floor(log2f(scale) + 0.5));
    return zoomLevel;
}
radven
  • 2,296
  • 1
  • 22
  • 39

4 Answers4

15

Imagine that the overlay is cloud cover - or in our case, cellular signal coverage. It might not "look good" while zoomed in deep, but the overlay is still conveying essential information to the user.

I've worked around the problem by adding an OverZoom mode to enhance Apple's TileMap sample code.

Here is the new tilesInMapRect function in TileOverlay.m:

- (NSArray *)tilesInMapRect:(MKMapRect)rect zoomScale:(MKZoomScale)scale
{
    NSInteger z = zoomScaleToZoomLevel(scale);

    // OverZoom Mode - Detect when we are zoomed beyond the tile set.
    NSInteger overZoom = 1;
    NSInteger zoomCap = MAX_ZOOM;  // A constant set to the max tile set depth.

    if (z > zoomCap) {
        // overZoom progression: 1, 2, 4, 8, etc...
        overZoom = pow(2, (z - zoomCap));
        z = zoomCap;
    }

    // When we are zoomed in beyond the tile set, use the tiles
    // from the maximum z-depth, but render them larger.
    NSInteger adjustedTileSize = overZoom * TILE_SIZE;

    // Number of tiles wide or high (but not wide * high)
    NSInteger tilesAtZ = pow(2, z);

    NSInteger minX = floor((MKMapRectGetMinX(rect) * scale) / adjustedTileSize);
    NSInteger maxX = floor((MKMapRectGetMaxX(rect) * scale) / adjustedTileSize);
    NSInteger minY = floor((MKMapRectGetMinY(rect) * scale) / adjustedTileSize);
    NSInteger maxY = floor((MKMapRectGetMaxY(rect) * scale) / adjustedTileSize);
    NSMutableArray *tiles = nil;

    for (NSInteger x = minX; x <= maxX; x++) {
        for (NSInteger y = minY; y <= maxY; y++) {

            // As in initWithTilePath, need to flip y index to match the gdal2tiles.py convention.
            NSInteger flippedY = abs(y + 1 - tilesAtZ);
            NSString *tileKey = [[NSString alloc] initWithFormat:@"%d/%d/%d", z, x, flippedY];
            if ([tilePaths containsObject:tileKey]) {
                if (!tiles) {
                    tiles = [NSMutableArray array];
                }
                MKMapRect frame = MKMapRectMake((double)(x * adjustedTileSize) / scale,
                                                (double)(y * adjustedTileSize) / scale,
                                                adjustedTileSize / scale,
                                                adjustedTileSize / scale);
                NSString *path = [[NSString alloc] initWithFormat:@"%@/%@.png", tileBase, tileKey];
                ImageTile *tile = [[ImageTile alloc] initWithFrame:frame path:path];
                [path release];
                [tiles addObject:tile];
                [tile release];
            }
            [tileKey release];
        }
    }
    return tiles;
}

And here is the new drawMapRect in TileOverlayView.m:

- (void)drawMapRect:(MKMapRect)mapRect
          zoomScale:(MKZoomScale)zoomScale
          inContext:(CGContextRef)context
{
    // OverZoom Mode - Detect when we are zoomed beyond the tile set.
    NSInteger z = zoomScaleToZoomLevel(zoomScale);
    NSInteger overZoom = 1;
    NSInteger zoomCap = MAX_ZOOM;

    if (z > zoomCap) {
        // overZoom progression: 1, 2, 4, 8, etc...
        overZoom = pow(2, (z - zoomCap));
    }

    TileOverlay *tileOverlay = (TileOverlay *)self.overlay;

    // Get the list of tile images from the model object for this mapRect.  The
    // list may be 1 or more images (but not 0 because canDrawMapRect would have
    // returned NO in that case).

    NSArray *tilesInRect = [tileOverlay tilesInMapRect:mapRect zoomScale:zoomScale];
    CGContextSetAlpha(context, tileAlpha);

    for (ImageTile *tile in tilesInRect) {
        // For each image tile, draw it in its corresponding MKMapRect frame
        CGRect rect = [self rectForMapRect:tile.frame];
        UIImage *image = [[UIImage alloc] initWithContentsOfFile:tile.imagePath];
        CGContextSaveGState(context);
        CGContextTranslateCTM(context, CGRectGetMinX(rect), CGRectGetMinY(rect));

        // OverZoom mode - 1 when using tiles as is, 2, 4, 8 etc when overzoomed.
        CGContextScaleCTM(context, overZoom/zoomScale, overZoom/zoomScale);
        CGContextTranslateCTM(context, 0, image.size.height);
        CGContextScaleCTM(context, 1, -1);
        CGContextDrawImage(context, CGRectMake(0, 0, image.size.width, image.size.height), [image CGImage]);
        CGContextRestoreGState(context);

        // Added release here because "Analyze" was reporting a potential leak. Bug in Apple's sample code?
        [image release];
    }
}

Seems to be working great now.

BTW - I think the TileMap sample code is missing an [image release] and was leaking memory. Note where I added it in the code above.

I hope that this helps some others with the same problem.

Cheers,

  • Chris
radven
  • 2,296
  • 1
  • 22
  • 39
  • 2
    Chris -- you're a lifesaver. I wasn't looking forward to creating a workaround, so I did a search, and your answer popped right up. I copied the `zoomScaleToZoomLevel` function and `TILE_SIZE` constant to the TileOverlayView.m file from TileOverlay.m so it wouldn't throw an error, but other than that it works flawlessly. Thanks a million! – James Skidmore Mar 29 '11 at 06:14
  • 1
    I'm using a different way to find my tiles as described at https://github.com/mtigas/iOS-MapLayerDemo, but the idea is the same basically. I'm having a hard time porting the over zoom to it though. Anyone done this? – Duane Fields Feb 14 '12 at 05:42
  • 1
    On a retina device, you have to multiple by the device scale factor, CGContextScaleCTM(context, overZoom/zoomScale * [UIScreen mainScreen].scale, overZoom/zoomScale * [UIScreen mainScreen].scale); which works fine, until you enter over zoom mode. – Duane Fields Feb 17 '12 at 23:37
  • 1
    I've got this working fine for 2x but now 1x checkerboards - basically skipping every other tile – earnshavian Nov 13 '12 at 22:03
  • 2
    Just before I implement something already implemented by somebody else... you don't have working sample code that work with the new iOS7 `MKTileOverlayRenderer`? – Georg Feb 26 '15 at 12:56
  • Did you find any sample code for MKTileOverlayRenderer? @Georg – mikkokut Apr 19 '20 at 05:42
2

Here's the swift conversion so no one else has to do this work again. Thanks @radven, this works wonderfully.

class TileOverlay: MKTileOverlay {
        return directoryUrl?.appendingPathComponent("TopoMaps/\(path.z)/\(path.x)/\(path.y)_\(path.x)_\(path.z).png")
            ?? Bundle.main.url(
            forResource: "default",
            withExtension: "png")!
    }
    
    func tiles(in rect: MKMapRect, zoomScale scale: MKZoomScale) -> [ImageTile]? {
        var z = zoomScaleToZoomLevel(scale)
        
        // OverZoom Mode - Detect when we are zoomed beyond the tile set.
        var overZoom = 1
        let zoomCap = MAX_ZOOM // A constant set to the max tile set depth.
        
        if z > zoomCap {
            // overZoom progression: 1, 2, 4, 8, etc...
            overZoom = Int(pow(2, Double(z - zoomCap)))
            z = zoomCap
        }
        
        // When we are zoomed in beyond the tile set, use the tiles
        // from the maximum z-depth, but render them larger.
        let adjustedTileSize = overZoom * Int(TILE_SIZE)
        
        // Number of tiles wide or high (but not wide * high)
        let tilesAtZ = Int(pow(2, Double(z)))
        
        let minX = Int(floor((rect.minX * Double(scale)) / Double(adjustedTileSize)))
        let maxX = Int(floor((rect.maxX * Double(scale)) / Double(adjustedTileSize)))
        let minY = Int(floor((rect.minY * Double(scale)) / Double(adjustedTileSize)))
        let maxY = Int(floor((rect.maxY * Double(scale)) / Double(adjustedTileSize)))
        var tiles: [ImageTile]? = nil
        
        for x in minX...maxX {
            for y in minY...maxY {
                
                if let url = directoryUrl?.appendingPathComponent("TopoMaps/\(z)/\(x)/\(y)_\(x)_\(z).png").relativePath,
                   FileManager.default.fileExists(atPath: url) {
                    if tiles == nil {
                        tiles = []
                    }
                    let frame = MKMapRect(
                        x: Double(x * adjustedTileSize) / Double(scale),
                        y: Double(y * adjustedTileSize) / Double(scale),
                        width: Double(CGFloat(adjustedTileSize) / scale),
                        height: Double(CGFloat(adjustedTileSize) / scale))
                    let tile = ImageTile(frame: frame, path: url)
                    tiles?.append(tile)
                }
            }
            
        }
        return tiles
        
    }
    
}

struct ImageTile {
    let frame: MKMapRect
    let path: String
}

class TileOverlayRenderer: MKOverlayRenderer {
    override func draw(
        _ mapRect: MKMapRect,
        zoomScale: MKZoomScale,
        in context: CGContext
    ) {
        // OverZoom Mode - Detect when we are zoomed beyond the tile set.
        let z = zoomScaleToZoomLevel(zoomScale)
        var overZoom = 1
        let zoomCap = MAX_ZOOM
        
        if z > zoomCap {
            // overZoom progression: 1, 2, 4, 8, etc...
            overZoom = Int(pow(2, Double(z - zoomCap)))
        }
        
        let tileOverlay = overlay as? TileOverlay
        
        // Get the list of tile images from the model object for this mapRect.  The
        // list may be 1 or more images (but not 0 because canDrawMapRect would have
        // returned NO in that case).
        
        let tilesInRect = tileOverlay?.tiles(in: mapRect, zoomScale: zoomScale)
        let tileAlpha: CGFloat = 1
        context.setAlpha(tileAlpha)
        
        for tile in tilesInRect ?? [] {
            // For each image tile, draw it in its corresponding MKMapRect frame
            let rect = self.rect(for: tile.frame)
            let image = UIImage(contentsOfFile: tile.path)
            context.saveGState()
            context.translateBy(x: rect.minX, y: rect.minY)
            
            if let cgImage = image?.cgImage, let width = image?.size.width, let height = image?.size.height {
                // OverZoom mode - 1 when using tiles as is, 2, 4, 8 etc when overzoomed.
                context.scaleBy(x: CGFloat(CGFloat(overZoom) / zoomScale), y: CGFloat(CGFloat(overZoom) / zoomScale))
                context.translateBy(x: 0, y: image?.size.height ?? 0.0)
                context.scaleBy(x: 1, y: -1)
                context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
                context.restoreGState()
                
                // Added release here because "Analyze" was reporting a potential leak. Bug in Apple's sample code?
            }
            
        }
    }
}

let MAX_ZOOM = 13
let TILE_SIZE: Double = 256

func zoomScaleToZoomLevel(_ scale: MKZoomScale) -> Int {
    let numTilesAt1_0 = MKMapSize.world.width / TILE_SIZE
    let zoomLevelAt1_0 = log2(numTilesAt1_0) // add 1 because the convention skips a virtual level with 1 tile.
    let zoomLevel = Int(max(0, zoomLevelAt1_0 + floor(Double(log2f(Float(scale))) + 0.5)))
    return zoomLevel
}
Callum
  • 141
  • 8
2

This algorithm seems to produce a lot of map tiles outside of the MapRect. Adding the following inside the loop to skip tiles outside the boundaries helps a lot:

if (! MKMapRectIntersectsRect(rect, tileMapRect))
   continue;
Duane Fields
  • 1,331
  • 12
  • 20
0

A bit late to the party, but... Under iOS 7.0 and greater, you can use the maximumZ property on MKTileOverlay. From the docs:

If you use different overlay objects to represent different tiles at different zoom levels, use this property to specify the maximum zoom level supported by this overlay’s tiles. At zoom level 0, tiles cover the entire world map; at zoom level 1, tiles cover 1/4 of the world; at zoom level 2, tiles cover 1/16 of the world, and so on. The map never tries to load tiles for a zoom level greater than the value specified by this property.

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id<MKOverlay>)overlay {

    if ([overlay isKindOfClass:[MKTileOverlay class]]) {

        MKTileOverlay *ovrly = (MKTileOverlay *)overlay;
        ovrly.maximumZ = 9;  // Set your maximum zoom level here
        MKTileOverlayRenderer *rndr = [[MKTileOverlayRenderer alloc] initWithTileOverlay:ovrly];
        return rndr;

    }

    return nil;
}
Stewart Macdonald
  • 2,062
  • 24
  • 27
  • 1
    That doesn't stop the map from being zoomed past the minimum - it just doesn't show the overlay at all meaning an underlying map appears instead or no map if canReplaceMapContent is shown – earnshavian May 16 '16 at 15:17