2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IMDFを使って屋内フロアマップを MapLibre Native for iOS で表示してみる

Last updated at Posted at 2025-12-22

IMDF を使って屋内フロアマップを MapLibre Native for iOS で表示してみる

この記事は MapLibre Advent Calendar 2025 の 22 日目の記事です。

また、FOSS4G Auckland 2025 で発表した Implementing Interactive Indoor Maps with MapLibre and IMDF の内容を日本語で記事化したものです。

はじめに

私たちは日常生活の約 80% を屋内で過ごしています。家やオフィスで生活することが多いですが、ショッピングモールや空港など、屋内での行動は多岐にわたります。

ショッピングモールや空港などの、人が多く集まる場所ではフロアマップの需要がとても高いです。ショッピングモールでは、インフォメーションセンターで紙のフロアマップが配布されている事例が多いです。スマホアプリでは、Apple Maps や Google Maps で、一部の施設に限り屋内のフロアマップを表示する機能を提供しています。

Google Maps における屋内フロアマップの実装方法は公開されていません。一方、Apple Maps は Indoor Mapping Data Format (IMDF) という公開された仕様を使用しており、この仕様は OGC (Open Geospatial Consortium) で標準仕様として公開されています。

本記事では、この IMDF を使って、オープンソースの地図ライブラリである MapLibre Native for iOS で屋内フロアマップを表示する方法を紹介します。

IMDF (Indoor Mapping Data Format) とは

Indoor Mapping Data Format (IMDF) は Apple が提唱した屋内空間をモデル化するためのデータフォーマットです。

IMDF の主な特徴

IMDF は地図アプリケーションに適した設計になっています。

  • GeoJSON ベースのフォーマット
  • 目的別に整理された複数のデータセットで構成されている

IMDF のデータセット構造

IMDF は、複数のデータセットで構成されており、それぞれが屋内空間の異なる側面を表現しています。

仕様書: https://docs.ogc.org/cs/20-094/index.html

必須ファイル

必須となっているファイルは 3 つです。

ファイル 説明
manifest データセットに関するメタデータ
address 物理的な住所情報
venue トップレベルの会場情報

空間・設備を定義するデータセット

これらのファイルは任意で設定できます。ただし、フロアマップを構成するためには Level や Unit の情報が必要である場合が多いです。

ファイル 説明
building 建物の構造
footprint 建物のフットプリント(外形)ジオメトリ
level フロア情報
unit 個別の部屋やスペース
opening 出入口
amenity アメニティ(トイレ、ATM など)
section ユーザーが定義した任意のエリア
fixture 固定設備・什器
anchor 推奨表示位置を示すポイント。テナント等が参照する基点
occupant テナント・占有者

IMDF の階層構造

IMDF データは階層構造で整理されています。このような階層構造により、屋内空間データを簡単に整理し、クエリできるようになっています。

IMDFStructure.png

参考資料: WWDC19 - Indoor Maps

Unit Feature の例

すべての Feature のデータセットを紹介すると記事の長さが大変なことになるので、例として Unit の GeoJSON を紹介します。Unit は IMDF の中でも最も重要な Feature の 1 つです。詳しい中身については 公式ドキュメント をご覧ください。

{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "Feature",
  "feature_type": "unit",
  "geometry": {
    "type": "Polygon",
    "coordinates": [
      [
        [100.0, 0.0],
        [101.0, 0.0],
        [101.0, 1.0],
        [100.0, 1.0],
        [100.0, 0.0]
      ]
    ]
  },
  "properties": {
    "category": "room",
    "restriction": null,
    "accessibility": null,
    "name": {
      "en": "Ball Room"
    },
    "alt_name": null,
    "display_point": {
      "type": "Point",
      "coordinates": [ 100.0, 1.0 ]
    },
    "level_id": "22222223-2222-2222-2222-222222222222"
  }
}

MapLibre Native for iOS での実装

IMDF で定義された屋内空間情報を使用して、MapLibre Native for iOS でフロアマップアプリケーションを実装していきます。

今回実装したサンプルアプリは GitHub で公開しています

実装するアプリケーションの機能

今回実装するサンプルアプリケーションでは、以下の機能を実装しています。

  • インタラクティブなフロアマップの表示
  • フロア切り替え機能
  • Annotation のタップによる情報表示
    • Amenity や Occupant をタップして情報を表示

実装の流れ

サンプルアプリの実装の流れは以下のとおりです。

  1. IMDF データセットのデコード → Swift モデルオブジェクトへの JSON データの変換
  2. Unit と Opening の表示 - MapLibre のスタイルレイヤーとして表示
  3. Amenity と Occupant の表示 - MapLibre の Annotation として表示
  4. レベルピッカーの実装 - フロア切り替え UI
  5. Annotation タップの処理 - 情報シートの表示

1. IMDF GeoJSON ファイルのデコード

最初に GeoJSON 形式の IMDF データセットを Swift モデルオブジェクトにデコードします。このデコード関数では、アーカイブディレクトリからすべての IMDF データセットをロードし、IMDF のレイヤー構造を反映した階層的なデータ構造を構築します。

// IMDFデータセットをVenueグラフに統合するエントリーポイント
func decode(_ imdfDirectory: URL) throws -> Venue {
  let archive = IMDFArchive(directory: imdfDirectory)

  // すべてのIMDF FeatureCollectionをロード
  let venueFeatures = try decodeFeatureCollection(from: .venue, in: archive).features
  let levelFeatures = try decodeFeatureCollection(from: .level, in: archive).features
  let unitFeatures = try decodeFeatureCollection(from: .unit, in: archive).features
  let openingFeatures = try decodeFeatureCollection(from: .opening, in: archive).features
  let amenityFeatures = try decodeFeatureCollection(from: .amenity, in: archive).features
  let occupantFeatures = try decodeFeatureCollection(from: .occupant, in: archive).features

  // モデル階層を構築
  let amenities = try decodeAmenities(from: amenityFeatures)
  let units = try decodeUnits(from: unitFeatures, amenities: amenities)
  let openings = try decodeOpenings(from: openingFeatures)
  let levels = try decodeLevels(from: levelFeatures, units: units, openings: openings)
  return try decodeVenue(from: venueFeatures, levels: levels)
}

// GeoJSONファイルをロードして型付きFeatureCollectionを生成
private func decodeFeatureCollection(
  from file: IMDFArchive.File,
  in archive: IMDFArchive
) throws -> FeatureCollection {
  let fileURL = archive.fileURL(for: file)

  guard let data = try? Data(contentsOf: fileURL) else {
    throw IMDFDecodeError.notFound
  }

  let decoder = JSONDecoder()
  decoder.keyDecodingStrategy = .convertFromSnakeCase  // snake_caseキーを処理
  return try decoder.decode(FeatureCollection.self, from: data)
}

データモデル構造

モデルクラスは、IMDF の階層的な関係を維持します。これらのモデルクラスは IMDF の構造図で見た階層関係を維持しています。Venue は Level を含み Level は Unit と Opening を含み Unit は Amenity と Occupant を含みます。

// VenueはordinalでインデックスされたLevelを含む
class Venue {
  struct Properties: Codable {
    let category: String
  }

  var identifier: UUID
  var properties: Properties
  var levelsByOrdinal: [Int: [Level]]
}

// LevelはUnitとOpeningを含む
class Level: NSObject {
  struct Properties: Codable {
    let ordinal: Int
    let category: String
    let shortName: Label
  }

  var identifier: UUID
  var properties: Properties
  var units: [Unit] = []
  var openings: [Opening] = []
}

// UnitはAmenityとOccupantを含む
class Unit: NSObject {
  struct Properties: Codable {
    let category: String
    let levelId: UUID
    let name: Label?
  }

  var identifier: UUID
  var properties: Properties
  var geometry: Polygonal
  var amenities: [Amenity] = []
  var occupants: [Occupant] = []
}

Occupant のデコード例

Occupant (テナント・占有者)をデコードする際は Anchor ポイントを経由して Unit に関連付けます。Occupant はジオメトリを IMDF 仕様で定義していないため Anchor ポイントと関連付ける必要があります。このため、データ変換時に Unit 内に含まれる Anchor を検索しています。

// Anchor参照を使用して各UnitにOccupantを配置
private func decodeOccupants(from features: [Feature], anchors: [Anchor], units: [Unit]) throws {
  for feature in features {
    guard
      let identifier = feature.identifier?.string,
      let uuid = UUID(uuidString: identifier),
      let properties = feature.properties
    else {
      throw IMDFDecodeError.invalidOccupant
    }

    let occupantProperties = try convertProperties(Occupant.Properties.self, from: properties)

    // OccupantのanchorIdからAnchorを検索
    guard
      let anchor = anchors.first(where: { anchor in anchor.identifier == occupantProperties.anchorId })
    else {
      throw IMDFDecodeError.invalidOccupant
    }

    // AnchorのunitIdからUnitを検索
    guard
      let unit = units.first(where: { unit in unit.identifier == anchor.properties.unitId })
    else {
      throw IMDFDecodeError.invalidOccupant
    }

    // Anchorのジオメトリを使用してOccupantを作成
    let occupant = Occupant(identifier: uuid, properties: occupantProperties, geometry: anchor.geometry)
    unit.occupants.append(occupant)
  }
}

2. フロア Feature の表示

ユーザーがフロアを切り替えると、この関数が選択されたレベルのすべての Feature をレンダリングします。

// 選択されたレベルが変更されたときにコントローラーから呼ばれるエントリーポイント
func showFeaturesForLevel(_ level: Level, on mapView: MLNMapView) {
  removeAll(from: mapView)  // 前のレベルのFeatureをクリア
  showUnitsForLevel(level, on: mapView)  // 新しいレベルのFeatureを表示
}

Unit の表示

Unit は MapLibre のポリゴン Feature に変換され、カテゴリベースのスタイリングが適用されます。

private func showUnitsForLevel(_ level: Level, on mapView: MLNMapView) {
  for unit in level.units {
    // IMDFポリゴンジオメトリをShapeに変換
    if case .polygon(let geometry) = unit.geometry {
      guard let firstPolygonCoordinates = geometry.coordinates.first else { continue }

      // 内部ポリゴン(ジオメトリの穴)を処理
      let interiorPolygons = geometry.coordinates.dropFirst().map { coordinates in
        MLNPolygon(coordinates: coordinates, count: UInt(coordinates.count))
      }

      // 属性を持つポリゴンFeatureを作成
      let shape = MLNPolygonFeature(
        coordinates: firstPolygonCoordinates,
        count: UInt(firstPolygonCoordinates.count),
        interiorPolygons: interiorPolygons
      )
      shape.attributes = [
        "id": unit.identifier.uuidString,
        "name": unit.properties.name?.bestLocalizedValue ?? "",
        "category": unit.properties.category
      ]

      // カテゴリに基づいてスタイルプロバイダーを作成(room, restroom, elevatorなど)
      let unitStyleProvider = UnitStyleProvider(
        sourceId: "units-\(unit.identifier.uuidString)",
        shape: shape,
        category: UnitStyleProvider.UnitCategory(rawValue: unit.properties.category) ?? .room
      )
      addUnits([unitStyleProvider], to: mapView)
    }

    addAmenities(unit.amenities, to: mapView)
    addOccupants(unit.occupants, to: mapView)
  }
}

Opening の表示

Opening は LineString の Feature として表示されます。

// ドアなどの開口部を表すラインオーバーレイを構築
let openingStyleProviders = level.openings.map { opening in
  let shape = MLNPolylineFeature(
    coordinates: opening.geometry.coordinates,
    count: UInt(opening.geometry.coordinates.count)
  )

  shape.attributes = [
    "id": opening.identifier.uuidString,
    "category": opening.properties.category
  ]

  return OpeningStyleProvider(
    sourceId: "openings-\(opening.identifier.uuidString)",
    shape: shape
  )
}

addOpenings(openingStyleProviders, to: mapView)

MapStyleProvider

次にマップのスタイルを定義します。MapStyleProvider プロトコルは Feature を MapLibre レイヤーとしてレンダリングする方法を定義します。

// Souce と Layer の作成方法を定義するプロトコル
protocol MapStyleProvider {
  var source: MLNSource { get }
  var layers: [MLNStyleLayer] { get }
  func createLayers(sourceId: String) -> [MLNStyleLayer]
}

// 共通機能を実装する基底クラス
class BaseMapStyleProvider: MapStyleProvider {
  private(set) var source: MLNSource
  private(set) var layers: [MLNStyleLayer] = []

  init(sourceId: String, shape: MLNShape) {
    self.source = MLNShapeSource(identifier: sourceId, shape: shape, options: nil)
    self.layers = createLayers(sourceId: sourceId)
  }

  func createLayers(sourceId: String) -> [MLNStyleLayer] {
    return []  // サブクラスでオーバーライド
  }
}

UnitStyleProvider

UnitStyleProvider は、カテゴリベースの色を使用して MLNFillStyleLayer と MLNLineStyleLayer を作成します。

class UnitStyleProvider: BaseMapStyleProvider {
  enum UnitCategory: String {
    case elevator, escalator, stairs
    case restroom, restroomMale = "restroom.male", restroomFemale = "restroom.female"
    case room, nonpublic, walkway, other

    var fillColor: UIColor? {
      switch self {
      case .elevator, .escalator, .stairs: return UIColor(named: "ElevatorFill")
      case .restroom, .restroomMale, .restroomFemale: return UIColor(named: "RestroomFill")
      case .room: return UIColor(named: "RoomFill")
      case .nonpublic: return UIColor(named: "NonPublicFill")
      case .walkway: return UIColor(named: "WalkwayFill")
      case .other: return UIColor(named: "DefaultUnitFill")
      }
    }
  }

  override func createLayers(sourceId: String) -> [MLNStyleLayer] {
    var layers: [MLNStyleLayer] = []

    // Unit内部のFillStyleLayerを作成
    let fillLayer = MLNFillStyleLayer(identifier: "\(sourceId)-fill", source: source)
    fillLayer.fillColor = NSExpression(forConstantValue: category.fillColor ?? UIColor.lightGray)
    layers.append(fillLayer)

    // Unit境界のLineStyleLayerを作成
    let lineLayer = MLNLineStyleLayer(identifier: "\(sourceId)-line", source: source)
    lineLayer.lineColor = NSExpression(forConstantValue: UIColor(named: "UnitStroke"))
    layers.append(lineLayer)

    return layers
  }
}

3. Annotation

Amenity と Occupant は MLNAnnotation としてマップビューに追加されます。下の例は Amenity を MapView に追加するメソッドです。Amenity のモデルは MLNAnnotation を継承しているため Annotation として MapView に追加します。

// AmenityをMapViewに追加
private func addAmenities(_ amenities: [Amenity], to mapView: MLNMapView) {
  currentAmenities.append(contentsOf: amenities)
  mapView.addAnnotations(amenities)  // AmenityはMLNAnnotationに準拠
}

MapAnnotationProvider

Amenity と Occupant は、カスタムビューを持つ Annotation として表示されます。

// カスタムAnnotationビューを作成するためのMapAnnotationProviderプロトコル
protocol MapAnnotationProvider {
  func createAnnotationView(
    _ mapView: MLNMapView,
    annotation: MLNAnnotation
  ) -> MLNAnnotationView?
}

extension Amenity: MapAnnotationProvider {
  func createAnnotationView(
    _ mapView: MLNMapView,
    annotation: MLNAnnotation
  ) -> MLNAnnotationView? {
    guard let amenity = annotation as? Amenity else { return nil }

    let reuseIdentifier = "\(amenity.identifier.uuidString)"
    var annotationView = mapView.dequeueReusableAnnotationView(
      withIdentifier: reuseIdentifier
    )

    if annotationView == nil {
      annotationView = CustomAnnotationView(reuseIdentifier: reuseIdentifier)
      annotationView?.backgroundColor = amenity.category?.backgroundColor ?? .gray
    }

    if let customView = annotationView as? CustomAnnotationView {
      customView.setLabelText(amenity.title)
    }

    return annotationView
  }
}

4. レベルピッカーの実装

LevelPickerViewController は、ユーザーがフロア間を切り替えることを可能にします。各ボタンは ordinal 値を持つフロアレベルを表します。このボタンがタップされるとデリゲートに通知し、マップがそのフロアの Feature を表示するようトリガーします。

protocol LevelPickerDelegate: AnyObject {
  func didSelectLevel(ordinal: Int)
}

class LevelPickerViewController: UIViewController {
  weak var delegate: LevelPickerDelegate?

  private let level1Button = UIButton(type: .system)
  private let level0Button = UIButton(type: .system)
  private let levelMinus1Button = UIButton(type: .system)

  private func setupButtons() {
    configureButton(level1Button, title: "1", ordinal: 1)
    configureButton(level0Button, title: "0", ordinal: 0)
    configureButton(levelMinus1Button, title: "-1", ordinal: -1)

    stackView.addArrangedSubview(level1Button)
    stackView.addArrangedSubview(level0Button)
    stackView.addArrangedSubview(levelMinus1Button)

    updateButtonSelection(ordinal: 1)  // フロア1から開始
  }

  @objc private func levelButtonTapped(_ sender: UIButton) {
    let ordinal = sender.tag
    updateButtonSelection(ordinal: ordinal)
    delegate?.didSelectLevel(ordinal: ordinal)
  }
}

5. Annotation のタップの処理

ユーザーが Annotation をタップすると、詳細情報をシートで表示します。ユーザーが Annotation をタップすると、まずラベルの背景を黄色に変更して視覚的にハイライトします。その後、そのアメニティまたはテナントの詳細情報を含む情報シートを表示します。

// Annotationがタップされたときに呼ばれるデリゲートメソッド
func mapView(_ mapView: MLNMapView, didSelect annotation: MLNAnnotation) {
  guard !(annotation is MLNUserLocation) else { return }

  // 選択されたAnnotationをハイライト
  if let annotationView = mapView.view(for: annotation) as? CustomAnnotationView {
    annotationView.setSelected(true)
  }

  showInformationSheet(for: annotation)
}

private func showInformationSheet(for annotation: MLNAnnotation) {
  // 既存のシートがあれば先に閉じる
  if let existingSheet = currentSheet {
    currentSheet = nil
    existingSheet.dismiss(animated: true) { [weak self] in
      self?.presentNewSheet(for: annotation)
    }
  } else {
    presentNewSheet(for: annotation)
  }
}

情報シートの表示

シートには、選択された Feature の名前、カテゴリ、営業時間が表示されます。このサンプルアプリでは Amenity の場合は名前とカテゴリのみを表示し、Occupant の場合は営業時間も表示します。

private func presentNewSheet(for annotation: MLNAnnotation) {
  let sheetVC = InformationSheetViewController()
  sheetVC.modalPresentationStyle = .pageSheet

  // Annotationタイプに基づいてシートの内容を設定
  if let amenity = annotation as? Amenity {
    sheetVC.annotationTitle = amenity.title
    sheetVC.category = amenity.properties.category
  } else if let occupant = annotation as? Occupant {
    sheetVC.annotationTitle = occupant.title
    sheetVC.category = occupant.properties.category
    sheetVC.hours = occupant.properties.hours  // Occupantのみが営業時間を持つ
  }

  // カスタムシートサイズを設定
  if let sheet = sheetVC.sheetPresentationController {
    let customDetent = UISheetPresentationController.Detent.custom(
      identifier: .init("customSmall")
    ) { _ in 240 }
    sheet.detents = [customDetent]
    sheet.prefersGrabberVisible = true
    sheet.preferredCornerRadius = 16
    sheet.largestUndimmedDetentIdentifier = .init("customSmall")
  }

  present(sheetVC, animated: true)
  currentSheet = sheetVC
}

アプリケーションのスクリーンショット

今回サンプルアプリで実装したアプリケーションの動作を示すスクリーンショットです。

マップ表示 情報シート レベル切り替え
app-screen-001.png app-screen-002.png app-screen-003.png

まとめ

IMDF と MapLibre を組み合わせることで Web やモバイルプラットフォーム向けのインタラクティブなフロアマップアプリケーションを構築できます。屋内フロアマップのデータ定義をするまでが大変ではありますが、例えば身近なところではテックカンファレンスのイベント案内などでも活用できると見込んでいます。

参考資料

※ サンプルコードでは、実際の IMDF データセットはライセンスの都合上リポジトリには含まれていません。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?