|
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) |
|
} |
|
} |
|
} |
My app works with a
styleURL
that references a style.json file contained locally within the app and tiles hosted online. The user can download tiles for offline use if they want. I'm not aware that having an online tile source is a requirement of MapBox. As far as I know, you can set thestyleURL
to get tiles from anywhere, including locally on the device.