Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Implementing Interactive Indoor Maps with MapLi...

Avatar for Haruki Inoue Haruki Inoue
November 19, 2025
11

Implementing Interactive Indoor Maps with MapLibre and IMDF

Event Link
https://2025.foss4g.org/program/schedule#event-4391-implementing-interactive-indoor-maps-with-maplibre-and-imdf

Learn to render interactive indoor maps with MapLibre and IMDF. This session guides you through the complete workflow, from data processing to creating a map with dynamic features like searching for open stores and locating amenities within large facilities.

Avatar for Haruki Inoue

Haruki Inoue

November 19, 2025
Tweet

More Decks by Haruki Inoue

Transcript

  1. Haruki Inoue Living in Japan Board member of OSGeo.JP iOS

    Application Engineer Working with GIS as a hobby and side projects Introduction 2
  2. 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 Agenda 3
  3. We spend approximately 80% of our lives indoors for example,

    in shopping malls, airports, and other commercial facilities. The Current State of Floor Maps 4
  4. 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 How We Access Floor Maps 5
  5. Google Maps and Apple Maps display interactive floor maps for

    some commercial facilities. Indoor Maps in Map Applications 6
  6. 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 How Indoor Maps are Implemented 7
  7. 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) What is IMDF? 8
  8. 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 Key Features of IMDF 9
  9. 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 IMDF Datasets 10
  10. { "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" } } Example: Some Unit Features Source: https://docs.ogc.org/cs/20-094/Unit/index.html 11
  11. IMDF has a hierarchical layered structure IMDF Layered Structure Source:

    https://developer.apple.com/videos/play/wwdc2019/241/ 12
  12. 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 Interactive Floor Map Application with MapLibre Native 13
  13. 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 Implementation Flow 14
  14. 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) } Decoding IMDF GeoJSON Files 15
  15. 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) } Decoding IMDF GeoJSON Files 16
  16. 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]] } // 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] = [] } // 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] = [] } Data Model Structure 17
  17. 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) } } Decoding Occupants (Example) 18
  18. 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 } Displaying Floor Features 19
  19. 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) } } Displaying Units 20
  20. 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) Displaying Openings 21
  21. 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 } } Style Providers for MapLibre 22
  22. 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 } } Unit Style Provider 23
  23. 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 } Annotations for MapLibre 24
  24. 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 } } Annotation Provider 25
  25. 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() override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) setupCircleView() setupLabel() } 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) } func setLabelText(_ text: String?) { label.text = text label.isHidden = (text == nil || text?.isEmpty == true) setNeedsLayout() } func setSelected(_ selected: Bool) { if selected { label.backgroundColor = .yellow } else { label.backgroundColor = UIColor.white.withAlphaComponent(0.8) } } 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 ) // Style the circle circleView.layer.cornerRadius = currentSize / 2 circleView.layer.borderWidth = 2 circleView.layer.borderColor = UIColor.white.cgColor // 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 ) } } } Custom Annotation View 26
  26. 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) } } Level Picker 27
  27. 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 } } Level Picker Button Styling 28
  28. 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) } } Annotation Tap Handling 29
  29. 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 } Information Sheet Presentation 30
  30. 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 } } } InformationSheetViewController 31
  31. 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 Future Possibilities 33
  32. 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 Summary 34
  33. 35