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 データは階層構造で整理されています。このような階層構造により、屋内空間データを簡単に整理し、クエリできるようになっています。
参考資料: 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 をタップして情報を表示
実装の流れ
サンプルアプリの実装の流れは以下のとおりです。
- IMDF データセットのデコード → Swift モデルオブジェクトへの JSON データの変換
- Unit と Opening の表示 - MapLibre のスタイルレイヤーとして表示
- Amenity と Occupant の表示 - MapLibre の Annotation として表示
- レベルピッカーの実装 - フロア切り替え UI
- 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
}
アプリケーションのスクリーンショット
今回サンプルアプリで実装したアプリケーションの動作を示すスクリーンショットです。
| マップ表示 | 情報シート | レベル切り替え |
|---|---|---|
![]() |
![]() |
![]() |
まとめ
IMDF と MapLibre を組み合わせることで Web やモバイルプラットフォーム向けのインタラクティブなフロアマップアプリケーションを構築できます。屋内フロアマップのデータ定義をするまでが大変ではありますが、例えば身近なところではテックカンファレンスのイベント案内などでも活用できると見込んでいます。
参考資料
- IMDF 仕様書: https://docs.ogc.org/cs/20-094/index.html
- サンプルコードリポジトリ: https://github.com/haruki-inoue-314/FOSS4G2025SampleApp
※ サンプルコードでは、実際の IMDF データセットはライセンスの都合上リポジトリには含まれていません。



