|
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) |
|
} |
|
} |
|
} |
@namannik
There is no certain way to add layers for vector source, unlike raster source, user can define many things (filter/zoom/type)with same vector data. For example, a single default Mapbox street style use 20 different layers for a single vector source.
I think we can directly put local host url into existing json style file. And define layers using this vector source in json style file. Later, just activate local host service, and tiles should be rendered.
As I mentioned, unlike raster source, add subclass of MGLVectorStyleLayer in code spend more efforts, I prefer using json style file.(Of course we can dynamically add all layers we want in code, but put them into style file should be the best choice)