⛰️

Mapbox Maps SDK for iOSの逆引き辞典

2024/08/27に公開

Mapbox Maps SDK for iOS を使った各種実装方法をまとめていきます。(公式のExamplesページ やリポジトリに同梱されているサンプルアプリを参照しつつ自分なりに噛み砕いてまとめたものになります)

地形を3D表示する

  • StyleURIsatelliteStreets を使用
mapView = MapView(frame: view.bounds, mapInitOptions: .init(styleURI: .satelliteStreets))
  • Terrain を追加
    • RasterDemSource の作成 → Terrain の作成 → MapboxMapsetTerrain (親クラスである StyleManager のメソッド)
    • Terrainexaggeration プロパティ: この値を乗算することで、地形の標高を誇張する。 初期値: 1、 値の範囲: [0, 1000]
var demSource = RasterDemSource(id: "mapbox-dem")
demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1"
// Setting the `tileSize` to 514 provides better performance and adds padding around the outside
// of the tiles.
demSource.tileSize = 514
demSource.maxzoom = 14.0
try! mapView.mapboxMap.addSource(demSource)

var terrain = Terrain(sourceId: "mapbox-dem")
terrain.exaggeration = .constant(1.5)

try! mapView.mapboxMap.setTerrain(terrain)

別パターン 1

  • StyleURI には streets を使用
let options = MapInitOptions(cameraOptions: camera, styleURI: .streets)
mapView = MapView(frame: view.bounds, mapInitOptions: options)
  • Terrain に加え、hillshade タイプのレイヤーを追加することで陰影をつける
    • RasterDemSource を再利用して hillshade タイプのレイヤーも追加
var demSource = RasterDemSource(id: "mapbox-dem")
demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1"
demSource.tileSize = 512
demSource.maxzoom = 14.0
try! mapView.mapboxMap.addSource(demSource)

var terrain = Terrain(sourceId: demSource.id)
terrain.exaggeration = .constant(0.5)
try! mapView.mapboxMap.setTerrain(terrain)

// Re-use terrain source for hillshade
let properties = [
    "id": "terrain_hillshade",
    "type": "hillshade",
    "source": demSource.id,
    "hillshade-illumination-anchor": "map"
] as [ String: Any ]

try! mapView.mapboxMap.addLayer(with: properties, layerPosition: .below("water"))

別パターン 2

  • カスタムスタイルを使用
if let url = URL(string: "mapbox://styles/mapbox-map-design/ckhqrf2tz0dt119ny6azh975y") {
    styleURI = StyleURI(url: url)
}
let mapInitOptions = MapInitOptions(cameraOptions: cameraOptions, styleURI: styleURI ?? .satelliteStreets)
mapView = MapView(frame: view.bounds, mapInitOptions: mapInitOptions)
  • Terrain を追加
    • hillshade レイヤーはなし
コード
var demSource = RasterDemSource(id: "mapbox-dem")
demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1"
demSource.tileSize = 514
demSource.maxzoom = 14.0
try! mapView.mapboxMap.addSource(demSource)

var terrain = Terrain(sourceId: demSource.id)
terrain.exaggeration = .constant(1.5)

do {
    try mapView.mapboxMap.setTerrain(terrain)
} catch {
    print("Failed to add a terrain layer to the map's style.")
}

建物を3D表示する

実装方法を見る前に、この "Extrusion" と呼ばれる建物を3D化する手法の「しくみ」について理解した方がコードを理解しやすいと思う: https://www.docswell.com/s/shu223/KN1RGE-GIS-iOS#p67

コードはこちら:

var layer = FillExtrusionLayer(id: "3d-buildings", source: "composite")

layer.minZoom                     = 15
layer.sourceLayer                 = "building"
layer.fillExtrusionColor   = .constant(StyleColor(.lightGray))
layer.fillExtrusionOpacity = .constant(0.6)

layer.filter = Exp(.eq) {
    Exp(.get) {
        "extrude"
    }
    "true"
}

layer.fillExtrusionHeight = .expression(
    Exp(.get) {
        "height"
    }
)

layer.fillExtrusionBase = .expression(
    Exp(.get) {
        "min_height"
    }
)

layer.fillExtrusionVerticalScale = .expression(
    Exp(.interpolate) {
        Exp(.linear)
        Exp(.zoom)
        15
        0
        15.05
        1
    }
)

layer.fillExtrusionAmbientOcclusionIntensity = .constant(0.3)
layer.fillExtrusionAmbientOcclusionRadius = .constant(3.0)

try! mapView.mapboxMap.addLayer(layer)

結構長いが、FillExtrusionLayer を作成し、マップに追加しているだけ。

しくみを理解した上でコードを読むと、地図のデータソースにある "building" レイヤーに対して、

  • "extrude" フィールドを "true" に設定
  • "height" フィールドの値を FillExtrusionLayerfillExtrusionHeight プロパティにセット
  • "min_height" フィールドの値を FillExtrusionLayerfillExtrusionBase プロパティにセット

ということをやっているのがしっくり腹落ちする。

カスタムスタイルを使用する


Blueprintスタイル

MapInitOptions を使う

  • styleJSON 引数を使う(styleURI引数よりこちらが優先される)
let jsonURL = Bundle.main.url(forResource: ..., withExtension: "json")!
let styleJSON = try String(contentsOf: jsonURL)
let options = MapInitOptions(styleJSON: styleJSON)
  • styleURI 引数を使う
let localStyleURL = Bundle.main.url(forResource: "blueprint_style", withExtension: "json")!
let styleURI = .StyleURI(url: localStyleURL)!
let options = MapInitOptions(styleURI: StyleURI(url: localStyleURL))

MapboxMap クラスのプロパティを使う

実際には親クラスの StyleManager のプロパティ

mapView.mapboxMap.styleJSON = styleJSON
mapView.mapboxMap.styleURI = styleURI

現在位置を示す Puck の表示

2D

let configuration = Puck2DConfiguration.makeDefault(showBearing: true)
mapView.location.options.puckType = .puck2D(configuration)

3D


人間のモデルが3D Puck(ダックのモデルは後述する ModelLayer`)

let uri = Bundle.main.url(forResource: "model", withExtension: "glb")
let model = Model(uri: uri, orientation: [0, 0, 180])
let configuration = Puck3DConfiguration(
    model: model,
    modelScale: .constant([50, 50, 50]),
    modelOpacity: .constant(0.5),
    layerPosition: .default
)
mapView.location.options.puckType = .puck3D(configuration)
mapView.location.options.puckBearing = .course
mapView.location.options.puckBearingEnabled = true

進行方向に追従させる

ビューポート(表示領域)を常に現在位置から進行方向を見た状態に追従させるには、 FollowPuckViewportState を使用する:

let followPuckViewportState = mapView.viewport.makeFollowPuckViewportState(
    options: FollowPuckViewportStateOptions(
        bearing: .heading))
mapView.viewport.transition(to: followPuckViewportState)

現在位置取得やトラッキングのコードを書く必要もない。

addLayer する階層を指定する

FillLayerLineLayer を単に addLayer してしまうと、

try mapView.mapboxMap.addLayer(landuseLayer)
try mapView.mapboxMap.addLayer(roadLayer)

こんな感じで Puck の上にレイヤーが描画されてしまったりする。

これを回避するには、addLayerlayerPosition 引数に LayerPosition を指定する:

try mapView.mapboxMap.addLayer(landuseLayer, layerPosition: .below("road-label"))
try mapView.mapboxMap.addLayer(roadLayer, layerPosition: .above("road-label"))

LayerPosition の定義は以下のようになっていて、.at でインデックスを直接指定できるほか、.above.belowLayerid を指定して相対位置を指定できる。

`LayerPosition` の定義
/// Specifies the position at which a layer will be added when using `Style.addLayer`.
public enum LayerPosition: Equatable, Codable {
    /// Default behavior; add to the top of the layers stack.
    case `default`

    /// Layer should be positioned above the specified layer id.
    case above(String)

    /// Layer should be positioned below the specified layer id.
    case below(String)

    /// Layer should be positioned at the specified index in the layers stack.
    case at(Int)

    internal var corePosition: CoreLayerPosition {
        switch self {
        case .default:
            return CoreLayerPosition()
        case .above(let layerId):
            return CoreLayerPosition(above: layerId)
        case .below(let layerId):
            return CoreLayerPosition(below: layerId)
        case .at(let index):
            return CoreLayerPosition(at: index)
        }
    }
}

レイヤーID一覧を出力する

addLayer する際に LayerPosition でインデックスやレイヤーIDを引数に渡して相対位置を指定できると上に書いたが、しかしそのレイヤーの階層やIDをどうやって知るのか、という問題がある。

allLayerIdentifiers プロパティで取得できる

mapView.mapboxMap.allLayerIdentifiers.forEach { layerInfo in
    print("Layer ID: \(layerInfo.id)")
}
blueprint.json スタイル使用時のレイヤー一覧:
land
waterway
water
water-gap-width
waterway copy
water-custom
land-structure-polygon
land-structure-line
water copy
ne-countries-fine
natural-earth-states
building-outline
building
tunnel-street-minor-low
tunnel-street-minor-case
tunnel-primary-secondary-tertiary-case
tunnel-major-link-case
tunnel-motorway-trunk-case
tunnel-path
tunnel-steps
tunnel-pedestrian
tunnel-major-link
tunnel-street-minor
tunnel-primary-secondary-tertiary
tunnel-motorway-trunk
road-minor-low
road-minor-case
road-street-low
road-street-case
road-secondary-tertiary-case
road-primary-case
road-major-link-case
road-motorway-trunk-case
road-path-bg
road-steps-bg
road-pedestrian-case
road-path
road-steps
road-pedestrian
road-major-link
road-minor
road-street
road-secondary-tertiary
road-primary
road-motorway-trunk
bridge-street-minor-low
bridge-street-minor-case
bridge-primary-secondary-tertiary-case
bridge-major-link-case
bridge-motorway-trunk-case
bridge-path-bg
bridge-steps-bg
bridge-pedestrian-case
bridge-path
bridge-steps
bridge-pedestrian
bridge-major-link
bridge-street-minor
bridge-primary-secondary-tertiary
bridge-motorway-trunk
bridge-major-link-2-case
bridge-motorway-trunk-2-case
bridge-major-link-2
bridge-motorway-trunk-2
admin-1-boundary-bg
admin-0-boundary-bg
admin-1-boundary
admin-0-boundary
admin-0-boundary-disputed
road-label
waterway-label
natural-line-label
natural-point-label
water-line-label
water-point-label
poi-label
settlement-subdivision-label
settlement-minor-label
settlement-major-label
puck-model-layer

3Dモデルを設置する

SceneKitを利用する方法もあるがここでは ModelLayer というレイヤーを利用する。

ModelLayer を利用するためには、MapboxMaps モジュールを次のように @_spi 属性をつけてimportする必要がある:

@_spi(Experimental) import MapboxMaps

ModelLayer を作成し、setMapStyleContent メソッドを通じて地図に追加する。

mapView.mapboxMap.setMapStyleContent {
    /// Add Models for both the duck and car using an id and a URL to the resource
    Model(id: Constants.duckModelId, uri: Constants.duck)
    Model(id: Constants.carModelId, uri: Constants.car)

    /// Add a GeoJSONSource to the map and add the two features with geometry information
    GeoJSONSource(id: Constants.sourceId)
        .data(.featureCollection(FeatureCollection(features: [duckFeature, carFeature])))

    /// Add a Model visualization layer which displays the two models stored in the GeoJSONSource according to the set properties
    ModelLayer(id: "model-layer-id", source: Constants.sourceId)
        .modelId(Exp(.get) { Constants.modelIdKey })
        .modelType(.common3d)
        .modelScale(x: 40, y: 40, z: 40)
        .modelTranslation(x: 0, y: 0, z: 0)
        .modelRotation(x: 0, y: 0, z: 90)
        .modelOpacity(0.7)
}

上のコードは2種類のモデルをそれぞれ別の位置に追加する場合のコードだが、同じモデルを複数追加したい場合はこうなる:

let duckFeatures = duckCoordinates.map { point in
    var duckFeature = Feature(geometry: point)
    duckFeature.properties = [Constants.modelIdKey: .string(Constants.duckModelId)]
    return duckFeature
}

mapView.mapboxMap.setMapStyleContent {
    Model(id: Constants.duckModelId, uri: Constants.duck)

    GeoJSONSource(id: Constants.sourceId)
        .data(.featureCollection(FeatureCollection(features: duckFeatures)))

    ModelLayer(id: "model-layer-id", source: Constants.sourceId)
        ...
}

外部ベクトルタイルを追加する

  • VectorSource を追加する
    • 以下は Mapillary のベクトルタイルを使用している例
var vectorSource = VectorSource(id: "mapillary")

// For sources using the {z}/{x}/{y} URL scheme, use the `tiles`
// property on `VectorSource` to set the URL.
vectorSource.tiles = ["https://tiles.mapillary.com/maps/vtp/mly1_public/2/{z}/{x}/{y}?access_token=xxxx"]
vectorSource.minzoom = 6
vectorSource.maxzoom = 14

try mapView.mapboxMap.addSource(vectorSource)
  • ↑の VectorSource を使ったレイヤーを追加する
var lineLayer = LineLayer(id: "line-layer", source: vectorSource.id)
lineLayer.sourceLayer = "sequence"
let lineColor = StyleColor(UIColor(red: 0.21, green: 0.69, blue: 0.43, alpha: 1.00))
lineLayer.lineColor = .constant(lineColor)
lineLayer.lineOpacity = .constant(0.6)
lineLayer.lineWidth = .constant(2.0)
lineLayer.lineCap = .constant(.round)

do {
    try mapView.mapboxMap.addLayer(lineLayer, layerPosition: .below("waterway-label"))
} catch let layerError {
    showAlert(with: layerError.localizedDescription)
}

"sequence" というレイヤー名は Mapillary のAPIドキュメントを調べるとわかる:

https://www.mapillary.com/developer/api-documentation#coverage-tiles

layer name: sequence

  • zoom: 6-14 (inclusive)
  • geometry: LineString
  • data source: images captured in a single collection, sorted by captured_at
  • properties
    • captured_at, int, timestamp in ms since epoch
    • creator_id, int, unique user ID of the image owner (not username)
    • id, string, ID of the sequence (the legacy sequence key)
    • image_id, int, ID of the "best" (first) image representing the sequence
    • organization_id, int, ID of the organization this image belongs to. It can be absent
    • is_pano, bool, if it is a panoramic sequence

ジオメトリが LineString であることも記載されている。

特定のエリアを抽出して可視化する


道路と立ち入り可能なエリアを抽出して着色

(これは公式サンプルにない実装)

立ち入り可能なエリアを抽出し可視化

  • フィルター式の定義
    • ソースの"landuse"レイヤーから、以下のものを抽出
      • タイプが "Polygon"
      • クラスが "park", "pedestrian", "path", "residential"
let landuseFilter = Exp(.all) {
    Exp(.eq) {
        "$type"
        "Polygon"
    }
    Exp(.match) {
        Expression(operator: .get, arguments: [.string("class")])
        ["park", "pedestrian", "path", "residential"]
        true
        false
    }
}
  • FillLayer を作成
    • フィルターで抽出したものを着色して可視化
var landuseLayer = FillLayer(id: "walkable-areas", source: "composite")
landuseLayer.sourceLayer = "landuse"
landuseLayer.filter = landuseFilter
landuseLayer.fillColor = .constant(.init(.green))
landuseLayer.fillOpacity = .constant(0.5)
  • レイヤーをスタイルに追加
try mapView.mapboxMap.addLayer(landuseLayer)

道路を抽出し可視化

  • フィルター式の定義
    • ソースの "road" レイヤーからタイプが "LineString" なものを抽出 [1]
let roadFilter = Exp(.all) {
    Exp(.eq) {
        "$type"
        "LineString"
    }
}
  • LineLayer を作成
var roadLayer = LineLayer(id: "road", source: "composite")
roadLayer.sourceLayer = "road"
roadLayer.lineColor = .constant(.init(.red))
roadLayer.lineOpacity = .constant(0.5)
roadLayer.lineWidth = .constant(3)
  • レイヤーをスタイルに追加
try mapView.mapboxMap.addLayer(roadLayer)
脚注
  1. "road" レイヤーはおそらく全部 "LineString" なのでこのフィルターは不要かもしれない ↩︎

Discussion