|
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) |
|
} |
|
} |
|
} |
Firstly, thanks for sharing this gist. It's proved to be a great starting point for our use case, which is to serve raster/vector mbtiles for offline mapping.
There were a couple of gotchas I encountered whilst putting together a prototype for vector tiles and I thought I'd mention them here in case anyone encounters similar issues:
Gzip
I had to manually un-gzip vector tiles before returning them from the GCDWebServer. Without doing so, I encountered an error as follows:
Unknown pbf field type exception
I used the library GzipSwift for the unzipping:
Note that I tried configuring the
GCDWebServerDataResponse
with"Content-Encoding": "gzip"
and this had no effect. I also tried setting.isGZipContentEncodingEnabled = true
and this too had no effect. Not sure if I'm missing something obvious here?Working Offline
If you're looking to serve tiles without an internet connection, then the following steps may be required:
I had to modify my .plist in the following way. Note that our apps target iOS 11+ (and thus 10+, where I think this change was introduced). To my knowledge the following is functionally equivalent, but preferred, to Step 4 (detailed in the opening post).
Secondly, I had to start the GCDWebServer as follows in order to access via 'http://localhost':
With those changes in place the (very early prototype) seems to function well enough that we're now once again considering Mapbox as a solution. Once again, a big thanks for the initial concept.