Labo288

プログラミングのこと、GISのこと、パソコンのこと、趣味のこと

Mapbox for iOSで自作のスタイルを設定する(好きな背景図を表示する)

はじめに

地図系のフレームワークで有名で、最近ではゼンリンとの提携がニュースになったりしたMapboxはiOS向けにもフレームワークを提供しています。

www.mapbox.com

Mapbox for iOSの導入

ただ地図を表示するだけなら、チュートリアルのとおりとてもシンプルに書けます。

import Mapbox
 
class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
 
    let url = URL(string: "mapbox://styles/mapbox/streets-v11") //Mapbox公式スタイル
    let mapView = MGLMapView(frame: view.bounds, styleURL: url)
    mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    mapView.setCenter(CLLocationCoordinate2D(latitude: 59.31, longitude: 18.06), zoomLevel: 9, animated: false)
    view.addSubview(mapView)
  }
}

コメントにちょっと書いてありますが、このフレームワークは基本的に公式スタイルを表示するように作られています。スタイルは.json形式のデータにも関わらずです。 構造的には公式スタイルを参照するのが最もラクですが、そのためにはAPIトークンが必要で、アクセス数に応じて料金が発生します。というのが面倒臭いわけです。 では、OpenStreetMapなどオープンデータを活用し、公式スタイルを使わなければ良いのではないか?という事で色々試しました。

Mapbox公式以外のスタイルを適用する

Mapboxのスタイルは、MGLMapViewの初期化時にstyleURLという引数で設定されます。 つまりURLを渡さなければなりませんが、どこかのサーバーにデータを置いておく、というのはナンセンスなので、ローカルで完結させたいです。 という訳で、①好きなデータ等を設定したスタイルを.json形式で作成する、②①のローカルアドレスを取得しMGLMapViewに渡す、という方針になりました。

①:スタイルを.json形式で作成する

Mapboxのスタイルのデータ構造は、Swift辞書としては以下のとおり表現できます。

    private var style:[String:Any] = [
        "version":8,
        "sources":[],
        "layers":[],
    ]

versionは8で固定です。sourcesはレイヤーデータの形式等の設定、layersはsourcesのデータをどのように表示するか(色など)の設定となっています。 つまりsourcesとlayersに適切な初期データを与えて辞書をつくり、.json形式で出力すれば良いわけです。

そんなわけで、Mapboxのスタイルに関する機能をひとまとめにしたMapStyleManagerというクラスを実装しました。

import Foundation

struct MapStyleManager {
    private var style:[String:Any] = [
        "version":8,
        "name":"basemap",
        "sources":[],
        "layers":[],
    ]
    
    mutating func applyDefault() {
        let defaultName = "国土地理院オルソ"
        let defaultTileUrl = "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg"
        let defaultAttributionUrl = "https://maps.gsi.go.jp/development/ichiran.html"
        self.setBasemap(name: defaultName, tileUrlStr: defaultTileUrl, attributionUrl:defaultAttributionUrl)
    }
    
    func getStyle() -> [String:Any] {
        return self.style
    }
    
    mutating func setStyle(styleDict:[String:Any]) {
        self.style = styleDict
    }
    
    mutating func setBasemap(name:String, tileUrlStr:String, attributionUrl:String="", tileSize:Int=256) {
        self.style["name"] = name
        
        let sources = [
            name:[
                "type":"raster",
                "tiles":[tileUrlStr],
                "attribution":"<a href='" + attributionUrl + "'>" + name + "</a>",
                "tileSize":tileSize
            ]
        ]
        
        let layers = [
            [
                "id":name,
                "type":"raster",
                "source":name,
                "minzoom":0,
                "maxzoom":18
            ]
        ]
        
        self.style["sources"] = sources
        self.style["layers"] = layers
    }
    
    func readJson(inputDir:String, filename:String) -> [String:Any] {
        let nsHomeDir = NSHomeDirectory()
        let inputPath = nsHomeDir + inputDir + "/" + filename + ".json"
        
        guard let jsonData = try? Data(contentsOf: URL(fileURLWithPath: inputPath) ) else {
            preconditionFailure("Failed to load JSON file")
        }
        
        guard let jsonObj = try? JSONSerialization.jsonObject(with: jsonData, options: []) else {
            preconditionFailure("Failed to parse JSON file")
        }
        
        let jsonDict = jsonObj as! [String:Any]
        return jsonDict
    }
    
    //By serializing styleDic(class prop, Swift Dictionary), WRITE .JSON.
    func writeJson(outputDir:String, filename:String) -> URL? {
        let nsHomeDir = NSHomeDirectory()
        let outputPath = nsHomeDir + outputDir + "/" + filename + ".json"
        
        do {
            let jsonData = try JSONSerialization.data(withJSONObject: self.style, options: .prettyPrinted)
            try jsonData.write(to: URL(fileURLWithPath: outputPath))
            return URL(fileURLWithPath: outputPath)
        } catch {
            print("error")
            return nil
        }
    }
}

このクラスはクラス変数として常にひとつのstyleを持ち、前述のメソッドなどでそのstyleを操作します。 styleは、各種クラスメソッドでのみ読み書き可能です。

各コードを解説します。 まず望みのスタイルを作成します。

    mutating func setBasemap(name:String, tileUrlStr:String, attributionUrl:String="", tileSize:Int=256) {
        let sources = [
            name:[
                "type":"raster",
                "tiles":[tileUrlStr],
                "attribution":"<a href='" + attributionUrl + "'>" + name + "</a>",
                "tileSize":tileSize
            ]
        ]
        
        let layers = [
            [
                "id":name,
                "type":"raster",
                "source":name,
                "minzoom":0,
                "maxzoom":18
            ]
        ]
        
        self.style["sources"] = sources
        self.style["layers"] = layers
    }

上記のコードは、望みのラスタータイルのURLなどを与えることで、Mapboxスタイル形式の辞書を作成するメソッドです。 レイヤーのnameとタイルのtileUrlStrを与えるとスタイルを作成します。 あとはこの辞書を.jsonファイルとして出力してやります。

    func writeJson(outputDir:String, filename:String) -> URL? {
        let nsHomeDir = NSHomeDirectory()
        let outputPath = nsHomeDir + outputDir + "/" + filename + ".json"
        
        do {
            let jsonData = try JSONSerialization.data(withJSONObject: self.style, options: .prettyPrinted)
            try jsonData.write(to: URL(fileURLWithPath: outputPath))
            return URL(fileURLWithPath: outputPath)
        } catch {
            print("error")
            return nil
        }
    }

この関数では、Swiftの辞書をJSON形式の文字列に変換し.jsonファイルとして出力します。 writeJson()は実行されるたびに、iOS内の/tmpにstyle.jsonを出力しそのURLを返します。/tmpは、アプリを終了した後のデータの保存は保証されない、一次保存用フォルダですので、今回のような用途にもってこいですね。

②:①のローカルアドレスを取得しMGLMapViewに渡す

①のローカルアドレスは、writeJson()の返り値です。

①、②を踏まえて、自前のスタイルを設定する場合、以下のとおり書けます。

var msManager = MapStyleManager()
msManager.applyDefault()//デフォルトは国土地理院オルソ
let tmpStyleUrl = msManager.writeJson(outputDir: "/tmp", filename: "style")
mapView = MGLMapView(frame: rect, styleURL: tmpStyleUrl!)

さて、これで純正スタイルを設定する必要がなくなりました。 API tokenを削除してみましたが、問題なく動作します。API tokenはMapboxスタイルにアクセスするためのものでした。 つまりアクセス数による課金だとか、そういった恐れがなくなります。

サンプル

国土地理院データを表示してみたサンプル

f:id:kiguchi999:20191002224707j:plain