|
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) |
|
} |
|
} |
|
} |
@songyuyang0918, using the MGLMapView+MBTiles extension, you can add as many MBTiles layers as you'd like. Just be sure to use the appropriate
index
when callinginsertMBTilesSource
to ensure the tiles are shown in the order you'd like.I'm not sure what would be the cause of the broken pipe error you're seeing.