Implementing Interactive Indoor Maps with MapLibre and IMDF
如果无法正常显示,请先停止浏览器的去广告插件。
1. Implementing Interactive Indoor Maps
with MapLibre and IMDF
Haruki Inoue
1
2. Introduction
Haruki Inoue
Living in Japan
Board member of OSGeo.JP
iOS Application Engineer
Working with GIS as a hobby and side projects
2
3. Agenda
1. The Current State of Floor Maps
2. How Indoor Maps are Implemented
3. What is IMDF (Indoor Mapping Data Format)?
4. Implementation with MapLibre
5. Future Possibilities
3
4. The Current State of Floor Maps
We spend approximately 80% of our lives indoors
for example, in shopping malls, airports, and other commercial facilities.
4
5. How We Access Floor Maps
We often have these questions when visiting commercial facilities:
"Where is the store I'm looking for?"
"How do I get from my current location to that store?"
"What time does this store close?"
When we need to navigate indoor spaces, we have several approaches:
Shopping malls often distribute paper-based floor maps
Official websites provide digital maps, but they're usually not interactive
5
6. Indoor Maps in Map Applications
Google Maps and Apple Maps display interactive floor
maps for some commercial facilities.
6
7. How Indoor Maps are Implemented
Google Maps
Implementation method is not publicly disclosed
Proprietary format and tooling
Apple Maps
Uses a format called Indoor Mapping Data Format (IMDF)
Publicly documented specification
7
8. What is IMDF?
Indoor Mapping Data Format (IMDF) is a unified specification
for modeling indoor spaces.
Background
Developed by Apple and announced at WWDC19
Designed for large public spaces (airports, shopping malls, train stations)
Submitted to Open Geospatial Consortium (OGC)
8
9. Key Features of IMDF
IMDF is designed to be map application-friendly
GeoJSON-based format
Easy to work with standard GIS tools
Navigation-focused
Optimized for providing indoor information and navigation
Layered structure
Consists of multiple datasets organized by purpose
9
10. IMDF Datasets
IMDF consists of multiple datasets, each representing different aspects of indoor spaces.
Specification: https://docs.ogc.org/cs/20-094/index.html
File
Description
manifest Metadata about the dataset
address Physical address information
venue Venue
building Building structure
footprint Building footprint geometry
level Floors
unit
Individual rooms and spaces
File
Description
opening Doors
amenity Amenities (restrooms, ATMs, etc.)
section Approximate Extent
fixture Desks and shelves
kiosk Simply shop counters
occupant Tenants and occupants
anchor Center point of the occupant
10
11. Example: Some Unit Features
{
}
"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"
}
Source: https://docs.ogc.org/cs/20-094/Unit/index.html
11
12. IMDF Layered Structure
IMDF has a hierarchical layered structure
Source: https://developer.apple.com/videos/play/wwdc2019/241/
12
13. Interactive Floor Map Application with MapLibre Native
I've implemented a sample iOS application that demonstrates interactive floor maps using
MapLibre Native with IMDF datasets.
Key Features
Display floor maps with interactive elements
Switch between different floor levels
Tap annotations to view detailed information
13
14. Implementation Flow
The implementation follows these steps:
1. Decode IMDF GeoJSON files into Swift model objects
2. Display Units and Openings as MapLibre style layers
3. Display Amenities and Occupants as MapLibre annotations
4. Implement Level Picker for floor switching
5. Handle annotation taps to display information sheets
14
15. Decoding IMDF GeoJSON Files
The decoder loads IMDF GeoJSON files and builds a hierarchical data structure.
// Entry point that wires together the IMDF dataset into a Venue graph
func decode(_ imdfDirectory: URL) throws -> Venue {
let archive = IMDFArchive(directory: imdfDirectory)
// Load all IMDF feature collections
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
// Build model hierarchy
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)
}
15
16. Decoding IMDF GeoJSON Files
Helper function to load and parse GeoJSON files with proper snake_case handling.
// Loads a GeoJSON file and produces a strongly typed 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 // Handle snake_case keys
return try decoder.decode(FeatureCollection.self, from: data)
}
16
17. Data Model Structure
Model classes maintain the hierarchical relationships between IMDF features.
// Venue contains levels organized by ordinal
class Venue {
struct Properties: Codable {
let category: String
}
var identifier: UUID
var properties: Properties
var levelsByOrdinal: [Int: [Level]]
// Unit contains amenities and occupants
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] = []
}
// Level contains units and openings
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] = []
}
}
17
18. Decoding Occupants (Example)
The decodeOccupants function linking Occupants to Units through Anchor points.
// Populates each Unit with its Occupants using anchor references.
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)
guard
let anchor = anchors.first(where: { anchor in anchor.identifier == occupantProperties.anchorId })
else {
throw IMDFDecodeError.invalidOccupant
}
guard
let unit = units.first(where: { unit in unit.identifier == anchor.properties.unitId })
else {
throw IMDFDecodeError.invalidOccupant
}
let occupant = Occupant(identifier: uuid, properties: occupantProperties, geometry: anchor.geometry)
unit.occupants.append(occupant)
}
}
18
19. Displaying Floor Features
When users switch floors, this function renders all features for the selected level.
// Entry point called by controllers when the selected level changes
func showFeaturesForLevel(_ level: Level, on mapView: MLNMapView) {
removeAll(from: mapView) // Clear previous level's features
showUnitsForLevel(level, on: mapView) // Display new level's features
}
19
20. Displaying Units
Units are converted to MapLibre polygon features with category-based styling.
private func showUnitsForLevel(_ level: Level, on mapView: MLNMapView) {
for unit in level.units {
// Convert IMDF polygon geometry to MapLibre shapes
if case .polygon(let geometry) = unit.geometry {
guard let firstPolygonCoordinates = geometry.coordinates.first else { continue }
// Handle interior polygons (holes in the geometry)
let interiorPolygons = geometry.coordinates.dropFirst().map { coordinates in
MLNPolygon(coordinates: coordinates, count: UInt(coordinates.count))
}
// Create polygon feature with attributes
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
]
}
}
}
// Create style provider based on category (room, restroom, elevator, etc.)
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)
20
21. Displaying Openings
Openings are displayed as line features on the map.
// Build line overlays representing openings such as doors
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)
21
22. Style Providers for MapLibre
The MapStyleProvider protocol defines how features are rendered as MapLibre layers.
// Protocol that defines how to create MapLibre sources and layers
protocol MapStyleProvider {
var source: MLNSource { get }
var layers: [MLNStyleLayer] { get }
func createLayers(sourceId: String) -> [MLNStyleLayer]
}
// Base class that implements common functionality
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 [] // Override in subclasses
}
}
22
23. Unit Style Provider
UnitStyleProvider creates fill and line layers with category-based colors.
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] = []
// Create fill layer for the unit interior
let fillLayer = MLNFillStyleLayer(identifier: "\(sourceId)-fill", source: source)
fillLayer.fillColor = NSExpression(forConstantValue: category.fillColor ?? UIColor.lightGray)
layers.append(fillLayer)
// Create line layer for the unit border
let lineLayer = MLNLineStyleLayer(identifier: "\(sourceId)-line", source: source)
lineLayer.lineColor = NSExpression(forConstantValue: UIColor(named: "UnitStroke"))
layers.append(lineLayer)
return layers
}
}
23
24. Annotations for MapLibre
Amenities and Occupants are added to the mapview as annotations.
Example code:
// Add amenity annotations to the map
private func addAmenities(_ amenities: [Amenity], to mapView: MLNMapView) {
currentAmenities.append(contentsOf: amenities)
mapView.addAnnotations(amenities) // Amenity conforms to MLNAnnotation
}
24
25. Annotation Provider
Amenities and Occupants are displayed as MapLibre annotations with custom views.
// MapAnnotationProvider protocol for creating custom annotation views
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
25
26. Custom Annotation View
CustomAnnotationView renders a colored circle with a label for amenities and occupants.
class CustomAnnotationView: MLNAnnotationView {
private let circleView = UIView()
private let circleSize: CGFloat = 16
private let label = UILabel()
func setSelected(_ selected: Bool) {
if selected {
label.backgroundColor = .yellow
} else {
label.backgroundColor = UIColor.white.withAlphaComponent(0.8)
}
}
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
setupCircleView()
setupLabel()
}
override func layoutSubviews() {
super.layoutSubviews()
// Center the circle
let currentSize = circleView.bounds.width > 0
? circleView.bounds.width : circleSize
let xOffset = (bounds.width - currentSize) / 2
let yOffset = (bounds.height - currentSize) / 2
circleView.frame = CGRect(
x: xOffset, y: yOffset,
width: currentSize, height: currentSize
)
private func setupCircleView() {
backgroundColor = .clear
addSubview(circleView)
}
private func setupLabel() {
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 12, weight: .medium)
label.backgroundColor = UIColor.white.withAlphaComponent(0.8)
label.layer.cornerRadius = 4
label.layer.masksToBounds = true
addSubview(label)
}
// Style the circle
circleView.layer.cornerRadius = currentSize / 2
circleView.layer.borderWidth = 2
circleView.layer.borderColor = UIColor.white.cgColor
func setLabelText(_ text: String?) {
label.text = text
label.isHidden = (text == nil || text?.isEmpty == true)
setNeedsLayout()
}
}
}
// Position label above circle
if !label.isHidden, let text = label.text, !text.isEmpty {
let labelSize = label.sizeThatFits(CGSize(width: 200, height: 30))
let labelX = (bounds.width - (labelSize.width + 8)) / 2
let labelY = circleView.frame.minY - labelSize.height - 8
label.frame = CGRect(
x: labelX, y: labelY,
width: labelSize.width + 8, height: labelSize.height + 4
)
}
26
27. Level Picker
LevelPickerViewController allows users to switch between floors.
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)
// Start with floor 1
}
}
@objc private func levelButtonTapped(_ sender: UIButton) {
let ordinal = sender.tag
updateButtonSelection(ordinal: ordinal)
delegate?.didSelectLevel(ordinal: ordinal)
}
27
28. Level Picker Button Styling
Visual feedback shows which floor is currently selected.
private func configureButton(_ button: UIButton, title: String, ordinal: Int) {
button.setTitle(title, for: .normal)
button.backgroundColor = .white
button.setTitleColor(.black, for: .normal)
button.layer.borderColor = UIColor.lightGray.cgColor
button.layer.borderWidth = 1
button.tag = ordinal // Store ordinal in tag
button.addTarget(self, action: #selector(levelButtonTapped(_:)), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 40),
button.widthAnchor.constraint(equalToConstant: 40)
])
}
private func updateButtonSelection(ordinal: Int) {
// Reset all buttons
[level1Button, level0Button, levelMinus1Button].forEach { button in
button.backgroundColor = .white
}
// Highlight selected button
switch ordinal {
case 1: level1Button.backgroundColor = .yellow
case 0: level0Button.backgroundColor = .yellow
case -1: levelMinus1Button.backgroundColor = .yellow
default: break
}
}
28
29. Annotation Tap Handling
When users tap annotations, we display detailed information in a sheet.
// MapLibre delegate method called when annotation is tapped
func mapView(_ mapView: MLNMapView, didSelect annotation: MLNAnnotation) {
guard !(annotation is MLNUserLocation) else { return }
// Highlight the selected annotation
if let annotationView = mapView.view(for: annotation) as? CustomAnnotationView {
annotationView.setSelected(true)
}
}
showInformationSheet(for: annotation)
private func showInformationSheet(for annotation: MLNAnnotation) {
// Dismiss any existing sheet first
if let existingSheet = currentSheet {
currentSheet = nil
existingSheet.dismiss(animated: true) { [weak self] in
self?.presentNewSheet(for: annotation)
}
} else {
presentNewSheet(for: annotation)
}
}
29
30. Information Sheet Presentation
The sheet displays name, category, and opening hours for the selected feature.
private func presentNewSheet(for annotation: MLNAnnotation) {
let sheetVC = InformationSheetViewController()
sheetVC.modalPresentationStyle = .pageSheet
// Set sheet content based on annotation type
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 // Only occupants have hours
}
// Configure custom sheet size
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
}
30
31. InformationSheetViewController
InformationSheetViewController displays detailed information with opening hours.
class InformationSheetViewController: UIViewController {
var annotationTitle: String?
var category: String = ""
var hours: String? // Opening hours string from IMDF
private func configureContent() {
titleLabel.text = annotationTitle ?? "Unknown"
categoryLabel.text = "Category: \(category)"
// Parse and display opening hours if available
if let hours = hours, !hours.isEmpty {
let decoder = OpeningHoursDecoder()
let dayHours = decoder.decode(hours) // Parse hours string into structured data
if dayHours.isEmpty {
hoursTitleLabel.isHidden = true
hoursStackView.isHidden = true
} else {
hoursTitleLabel.isHidden = false
hoursStackView.isHidden = false
// Create a label for each day of the week
for dayHour in dayHours {
let dayLabel = createDayLabel(for: dayHour)
hoursStackView.addArrangedSubview(dayLabel)
}
}
}
}
} else {
hoursTitleLabel.isHidden = true
hoursStackView.isHidden = true
}
31
32. Application Screenshots
Map Display
Information Sheet
Level Switching
32
33. Future Possibilities
IMDF enables many advanced indoor mapping applications beyond what we've
implemented:
Real-time congestion monitoring: Integrate IoT sensors to display crowd density
Seamless indoor-outdoor navigation: Navigate from street level into buildings
Turn-by-turn indoor navigation: Guide users to their destination with indoor
positioning
Technologies: Wi-Fi fingerprinting, Bluetooth beacons, UWB
Accessibility features: Highlight wheelchair-accessible routes and facilities
Emergency response: Optimized evacuation routes and emergency service
navigation
33
34. Summary
IMDF is an OGC Community Standard for indoor mapping
Navigation-focused format, different from BIM
MapLibre + IMDF enables interactive floor map applications
Open-source solution for mobile platforms
Future possibilities: Real-time navigation, accessibility features, and more
References
IMDF Specification: https://docs.ogc.org/cs/20-094/index.html
Sample code repository: https://github.com/haruki-inoue-
314/FOSS4G2025SampleApp
34
35. 35