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

trang chủ - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-05-15 15:54
浙ICP备14020137号-1 $bản đồ khách truy cập$