|
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) |
|
} |
|
} |
|
} |
Great piece of sample code, it works for me with vector tiles. But the only problem is that when I start to pan and zoom, the tiles disappear for some zoom ranges. Does this mean that the
.mbtiles
file misses tiles for this range?