Skip to content

Instantly share code, notes, and snippets.

@namannik
Last active August 7, 2024 03:28
Show Gist options
  • Save namannik/3b7c8b69c2d0768d0c2b48d2ed5ff71c to your computer and use it in GitHub Desktop.
Save namannik/3b7c8b69c2d0768d0c2b48d2ed5ff71c to your computer and use it in GitHub Desktop.
MGLMapView+MBTiles

MGLMapView+MBTiles.swift

What it does

It enables an MGLMapView (from the Mapbox Maps iOS SDK) to display MBTiles.

How it works

It adds an extension to MGLMapView for adding MBTiles sources. When an MBTiles source is added to an MGLMapView, it starts a web server within your app and points the map's style to localhost.

Installation

  1. Add MGLMapView+MBTiles.swift (from this Gist) to your app.
  2. Add the GCDWebServer library to your app.
  3. Add the SQLite.swift library to your app.
  4. Add the following to your app's Info.plist:
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSExceptionDomains</key>
        <dict>
            <key>localhost</key>
            <dict>
                <key>NSExceptionAllowsInsecureHTTPLoads</key>
                <true/>
                <key>NSIncludesSubdomains</key>
                <false/>
            </dict>
        </dict>
    </dict> 

Usage

Add a source to mapView
let path = "/path/to/source.mbtiles"
if let source = try? MBTilesSource(filePath: path),
   let index = mapView.style?.layers.count {
    try? mapView.insertMBTilesSource(source, at: index)
}
Remove a source from mapView
mapView.remove(MBTilesSource: source)

Known Limitations

• Only MBTiles that contain raster data sources (jpg, png) are currently supported. Vector sources (i.e. pbf) are not (yet) supported.

Why?

I created this for my app, Forest Maps.

import Foundation
import Mapbox
import SQLite
// MARK: MbtilesSource
enum MBTilesSourceError: Error {
case CouldNotReadFileError
case UnknownFormatError
case UnsupportedFormatError
}
class MBTilesSource: NSObject {
// MBTiles spec, including details about vector tiles:
// https://github.com/mapbox/mbtiles-spec/
// MARK: Properties
var filePath: String
fileprivate var isVector = false
fileprivate var tileSize: Int?
fileprivate var layersJson: String?
fileprivate var attribution: String?
var minZoom: Float?
var maxZoom: Float?
var bounds: MGLCoordinateBounds?
fileprivate var mglTileSource: MGLTileSource?
fileprivate var mglStyleLayer: MGLForegroundStyleLayer?
var isVisible: Bool {
get {
return mglStyleLayer == nil ? false : mglStyleLayer!.isVisible
}
set {
mglStyleLayer?.isVisible = newValue
}
}
private var database: Connection?
private let validRasterFormats = ["jpg", "png"]
private let validVectorFormats = ["pbf", "mvt"]
// MARK: Functions
init(filePath: String) throws {
self.filePath = filePath
super.init()
do {
self.database = try Connection(filePath, readonly: true)
guard let anyTile = try database?.scalar("SELECT tile_data FROM tiles LIMIT 1") as? Blob else {
throw MBTilesSourceError.CouldNotReadFileError
}
let tileData = Data(bytes: anyTile.bytes)
// https://stackoverflow.com/a/42104538
var format: String?
let headerData = [UInt8](tileData)[0]
if headerData == 0x89 {
format = "png"
} else if headerData == 0xFF {
format = "jpg"
} else {
format = getMetadata(fieldName: "format")
}
if format == nil {
throw MBTilesSourceError.UnknownFormatError
} else if validRasterFormats.contains(format!) {
isVector = false
} else if validVectorFormats.contains(format!) {
isVector = true
} else {
throw MBTilesSourceError.UnsupportedFormatError
}
if let tileImage = UIImage(data: tileData) {
let screenScale = UIScreen.main.scale > 1 ? 2 : 1
self.tileSize = Int(tileImage.size.height / CGFloat(screenScale))
}
if let minString = getMetadata(fieldName: "minzoom") {
minZoom = Float(minString)
}
if let maxString = getMetadata(fieldName: "maxzoom") {
maxZoom = Float(maxString)
}
if let boundsString = getMetadata(fieldName: "bounds") {
let coordinates = boundsString.split(separator: ",")
if let west = CLLocationDegrees(coordinates[0]),
let south = CLLocationDegrees(coordinates[1]),
let east = CLLocationDegrees(coordinates[2]),
let north = CLLocationDegrees(coordinates[3]) {
bounds = MGLCoordinateBoundsMake(CLLocationCoordinate2DMake(south, west),
CLLocationCoordinate2DMake(north, east))
}
}
attribution = getMetadata(fieldName: "attribution")
layersJson = getMetadata(fieldName: "json")
} catch {
throw MBTilesSourceError.CouldNotReadFileError
}
}
deinit {
database = nil
}
private func getMetadata(fieldName: String) -> String? {
let query = "SELECT value FROM metadata WHERE name=\"\(fieldName)\""
if let binding = try? database?.scalar(query) {
return binding as? String
}
return nil
}
fileprivate func getTile(x: Int, y: Int, z: Int) -> Data? {
let query = "SELECT tile_data FROM tiles WHERE tile_column=\(x) AND tile_row=\(y) AND zoom_level=\(z)"
if let binding = try? database?.scalar(query),
let blob = binding as? Blob {
return Data(bytes: blob.bytes)
}
return nil
}
}
// MARK: - MGLMapView Extension
extension MGLMapView {
func insertMBTilesSource(_ source: MBTilesSource, at index: UInt, tileSize: Int? = nil) throws {
remove(MBTilesSource: source)
let port: UInt = 54321
MbtilesServer.shared.start(port: port)
MbtilesServer.shared.sources[source.filePath] = source
let filePath = source.filePath as NSString
guard let escapedPath = filePath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlPathAllowed) else {
return
}
let id = filePath.lastPathComponent
let urlTemplate = "http://localhost:\(port)\(escapedPath)?x={x}&y={y}&z={z}"
var options = [MGLTileSourceOption : Any]()
options[MGLTileSourceOption.tileCoordinateSystem] = MGLTileCoordinateSystem.TMS.rawValue
options[MGLTileSourceOption.tileSize] = tileSize ?? source.tileSize
options[MGLTileSourceOption.minimumZoomLevel] = source.minZoom
options[MGLTileSourceOption.maximumZoomLevel] = source.maxZoom
if let bounds = source.bounds {
options[MGLTileSourceOption.coordinateBounds] = NSValue(mglCoordinateBounds: bounds)
}
if let attributionString = source.attribution {
options[MGLTileSourceOption.attributionInfos] = [MGLAttributionInfo(title: NSAttributedString(string: attributionString) , url: nil)]
}
if source.isVector {
// Only raster data (jpg, png) is currently supported.
// Raster data (i.e. pbf) is not (yet) supported.
throw MBTilesSourceError.UnsupportedFormatError
} else {
source.mglTileSource = MGLRasterTileSource(identifier: id, tileURLTemplates: [urlTemplate], options: options)
source.mglStyleLayer = MGLRasterStyleLayer(identifier: id, source: source.mglTileSource!)
}
if source.minZoom != nil {
source.mglStyleLayer!.minimumZoomLevel = source.minZoom!
}
if source.maxZoom != nil {
source.mglStyleLayer!.maximumZoomLevel = source.maxZoom!
}
DispatchQueue.main.async { [weak self] in
self?.style?.addSource(source.mglTileSource!)
self?.style?.insertLayer(source.mglStyleLayer!, at: index)
}
}
func remove(MBTilesSource source: MBTilesSource) {
let filePath = source.filePath
let id = (filePath as NSString).lastPathComponent
DispatchQueue.main.async { [weak self] in
if let layer = self?.style?.layer(withIdentifier: id) {
self?.style?.removeLayer(layer)
}
if let source = self?.style?.source(withIdentifier: id) {
self?.style?.removeSource(source)
}
}
MbtilesServer.shared.sources[filePath] = nil
}
}
// MARK: - Server
fileprivate class MbtilesServer: NSObject {
// MARK: Properties
static let shared = MbtilesServer()
var sources = [String : MBTilesSource]()
private var webServer: GCDWebServer? = nil
// MARK: Functions
func start(port: UInt) {
guard Thread.isMainThread else {
DispatchQueue.main.sync { [weak self] in
self?.start(port: port)
}
return
}
if webServer == nil {
webServer = GCDWebServer()
}
guard !webServer!.isRunning else {
return
}
GCDWebServer.setLogLevel(3)
webServer?.addDefaultHandler(forMethod: "GET", request: GCDWebServerRequest.self, processBlock: { [weak self] (request: GCDWebServerRequest) -> GCDWebServerResponse? in
return self?.handleGetRequest(request)
})
webServer?.start(withPort: port, bonjourName: nil)
}
func stop() {
guard Thread.isMainThread else {
DispatchQueue.main.sync { [weak self] in
self?.stop()
}
return
}
webServer?.stop()
webServer?.removeAllHandlers()
}
private func handleGetRequest(_ request: GCDWebServerRequest) -> GCDWebServerResponse? {
if (request.path as NSString).pathExtension == "mbtiles",
let source = sources[request.path],
let query = request.query,
let xString = query["x"] as? String,
let yString = query["y"] as? String,
let zString = query["z"] as? String,
let x = Int(xString),
let y = Int(yString),
let z = Int(zString),
let tileData = source.getTile(x: x, y: y, z: z) {
return GCDWebServerDataResponse(data: tileData, contentType: "")
} else {
return GCDWebServerResponse(statusCode: 404)
}
}
}
@folsze
Copy link

folsze commented Apr 10, 2024

@hjhimanshu01 @namannik

Do you know whether this is possible with https://docs.mapbox.com/mapbox-gl-js/api/ for hybrid web wrapper native apps?

If not:
I am thinking of developing a plugin. How hard would that be, to get that to work? What is standing in the way currently?

@zanaamedi
Copy link

I am having trouble with GCDWebServer and other similar tools. The served tiles are not accessible in browser although I made sure the server is set up and running correctly. Could this be related to beta version of Xcode and MacOS I am currently using? (I can’t downgrade for other reasons. @namannik

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment