Indoor positioning

ArcGIS IPS is an indoor positioning system that allows you to locate yourself and others inside a building in real time. Similar to GPS, it puts a blue dot on indoor maps and uses location services to help you navigate to any point of interest or destination.

ArcGIS IPS supports:

  • Real-time indoor wayfinding
  • Real-time indoors location tracking and sharing
  • Real-time indoor location data collection
  • Indoor analytics

Your ArcGIS Runtime app can work with ArcGIS IPS to show device location using an indoor location data source.

Indoor positioning in a mobile app

How ArcGIS IPS works

ArcGIS IPS provides geoprocessing tools for setting up and authoring your IPS environment in ArcGIS Pro. It also includes ArcGIS Setup, a mobile app to collect radio signals from Bluetooth Low Energy (BLE) Beacons inside your building(s) to enable the indoor positioning system. It can make use of an existing or new beacon infrastructure and is beacon vendor agnostic.

See Get started with ArcGIS IPS in the ArcGIS Pro documentation for detailed instructions for setting up an indoor positioning system.

Floor-aware maps

ArcGIS Setup supports two types of floor-aware maps:

The ArcGIS Setup app automatically displays a floor picker when one of the two supported floor-aware maps is used. The floor picker is a custom component that you might want to implement in your own ArcGIS Runtime app. For an ArcGIS Indoors map, you can use the AGSFloorManager class to expose available sites, facilities, and levels in your floor picker. For an ArcGIS IPS map, you'll need to also implement a custom floor manager component to manage those datasets in a floor picker.

IPS positioning table

An IPS positioning feature table is stored with an IPS-enabled map. Each row in the table contains an indoor positioning file. The positioning file is created when setting up the IPS environment using the Generate positioning file geoprocessing tool. The tool processes one or more indoor surveys and creates a new row in the IPS positioning table with the positioning file (as an attachment) along with the date and time it was created. When working with an indoor location source in your ArcGIS Runtime app, the most recent positioning file is used unless you specify a different one, as described in the following section.

Indoor location data source

Your app can work with indoor positioning by using a location data source and the geoview's location display. The basics are the same as working with any other AGSLocationDataSource. The AGSIndoorsLocationDataSource wraps the logic to find location using an indoor positioning file, Bluetooth signals, and information from device sensors. The AGSIndoorsLocationDataSource provides the geographic location along with other metadata, such as the floor.

To work with ArcGIS IPS, the constructor for the AGSIndoorsLocationDataSource requires an IPS positioning table. Optionally, you can provide the following.

  • Row ID: A globally unique ID that identifies a row in the IPS positioning table. The positioning file associated with this row will be used to find indoor locations. If not specified in the constructor, the positioning file from the most recent survey is used.

  • Pathways table: An AGSArcGISFeatureTable with line features that represent paths through the indoor space. Locations provided by the AGSIndoorsLocationDataSource are snapped to the lines in this feature class.

    In the image below, the red + represents raw locations determined by the IPS. These locations are snapped to the nearest line feature in the pathways feature table before being displayed. This provides a more consistent display of the blue dot as it moves across the map.

    Locations are snapped to line features in the pathways feature table

You can handle a status changed event for the AGSIndoorsLocationDataSource to be notified when the location data source starts, stops, or fails to start.

Caching the positioning file

Upon creation, an AGSIndoorsLocationDataSource caches its positioning file on the device. The next time an indoors location data source is created for the same positioning table, the cached version of the file will be used under any of these circumstances:

  • The cached positioning file is requested in the constructor (by row ID).
  • A row ID isn't provided in the constructor and the cached positioning file is determined to be the most recent one available.
  • No internet connection is available.

Handle location change

You can handle the location changed event for the AGSIndoorsLocationDataSource to read information about the current AGSLocation.

An IPS location populates additional properties with the current floor and the transmitter (beacon) count. When the floor changes, you can update the map to filter the display of features for the current floor. The floor value returned with the location is an integer that represents the vertical offset, where 0 is the ground floor. This value increases by one for each floor above the ground floor and decreases by one for each floor below.

Use code like the following to read additional properties for the location when it changes.

Use dark colors for code blocks
                                                           
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

positioningTable.queryFeatures(with: queryParameters) { [weak self] result, error in
    guard let self = self else { return }

    guard let result = result, error == nil else {
        self.showError(IPSError.failedToLoadIPS)
        return
    }

    guard let feature = result.featureEnumerator().nextObject() else {
        self.showError(IPSError.noIPSDataFound)
        return
    }

    // The ID that identifies a row in the positioning table. AGSIndoorsLocationDataSource can be initialized without a row ID.
    guard let globalID = feature.attributes["GlobalID"] as? UUID else {
        self.showError(IPSError.mapDoesNotSupportIPS)
        return
    }

    let pathwaysTable = (map.operationalLayers as! [AGSFeatureLayer]).first(where: {$0.name == self.pathwaysTableName})?.featureTable as? AGSArcGISFeatureTable

    // Setting up AGSIndoorsLocationDataSource with positioning, pathways tables and positioning ID.
    // positioningTable - the "ips_positioning" feature table from an IPS-enabled map.
    // pathwaysTable - An AGSArcGISFeatureTable that contains pathways as per the ArcGIS Indoors Information Model.
    // Setting this property enables path snapping of locations provided by the AGSIndoorsLocationDataSource.
    // positioningID - an ID which identifies a specific row in the positioningTable that should be used for setting up IPS.
    self.indoorLocationDataSource = AGSIndoorsLocationDataSource(positioningTable: positioningTable, pathwaysTable: pathwaysTable, positioningID: globalID)
    // The delegate which will receive location, heading and status updates from the data source.
    self.indoorLocationDataSource?.locationChangeHandlerDelegate = self
    self.setupLocationDisplay()
}



currentFloor = location.additionalSourceProperties[.floor] as? Int
floorLabel.text = "Floor: \(location.additionalSourceProperties[.floor], default: "")"
positionSourceLabel.text = "Position source: \(location.additionalSourceProperties[.positionSource], default: "")"
transmitterCountLabel.text = "Transmitter count: \(location.additionalSourceProperties[.transmitterCount], default: "")"
horizontalAccuracyLabel.text = "Horizontal accuracy: \(numberFormatter.string(from: NSNumber(value: location.horizontalAccuracy)), default: "")"



let featureLayers = layers.filter{ $0 is AGSFeatureLayer} as! [AGSFeatureLayer]
featureLayers.forEach { featureLayer in
    featureLayer.definitionExpression = "VERTICAL_ORDER = \(currentFloor)"
}



private func setupLocationDisplay() {
    mapView.locationDisplay.dataSource = indoorLocationDataSource
    mapView.locationDisplay.autoPanMode = AGSLocationDisplayAutoPanMode.compassNavigation
    // Asynchronously start of the location display, which will in-turn start IndoorsLocationDataSource to start receiving IPS updates.
    mapView.locationDisplay.start { [weak self] (error) in
        guard let self = self, let error = error else { return }
        self.showError(error)
    }
}

In addition to getting the floor, you can get the position source, which will be BLE (Bluetooth Low Energy) when using IPS and GNSS (Global Navigation Satellite Systems) when using GPS. You can also get the count of transmitters (beacons) or satellites used to determine the location.

You can use a definition expression to filter layers in the map to only show features for the current floor. For efficiency, you should only filter features when the floor changes rather than with each location update. Depending on the schema for your floor-aware data, you may need to map the vertical offset value to a level ID in order to filter features by floor (level).

Use dark colors for code blocks
                                                           
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

positioningTable.queryFeatures(with: queryParameters) { [weak self] result, error in
    guard let self = self else { return }

    guard let result = result, error == nil else {
        self.showError(IPSError.failedToLoadIPS)
        return
    }

    guard let feature = result.featureEnumerator().nextObject() else {
        self.showError(IPSError.noIPSDataFound)
        return
    }

    // The ID that identifies a row in the positioning table. AGSIndoorsLocationDataSource can be initialized without a row ID.
    guard let globalID = feature.attributes["GlobalID"] as? UUID else {
        self.showError(IPSError.mapDoesNotSupportIPS)
        return
    }

    let pathwaysTable = (map.operationalLayers as! [AGSFeatureLayer]).first(where: {$0.name == self.pathwaysTableName})?.featureTable as? AGSArcGISFeatureTable

    // Setting up AGSIndoorsLocationDataSource with positioning, pathways tables and positioning ID.
    // positioningTable - the "ips_positioning" feature table from an IPS-enabled map.
    // pathwaysTable - An AGSArcGISFeatureTable that contains pathways as per the ArcGIS Indoors Information Model.
    // Setting this property enables path snapping of locations provided by the AGSIndoorsLocationDataSource.
    // positioningID - an ID which identifies a specific row in the positioningTable that should be used for setting up IPS.
    self.indoorLocationDataSource = AGSIndoorsLocationDataSource(positioningTable: positioningTable, pathwaysTable: pathwaysTable, positioningID: globalID)
    // The delegate which will receive location, heading and status updates from the data source.
    self.indoorLocationDataSource?.locationChangeHandlerDelegate = self
    self.setupLocationDisplay()
}



currentFloor = location.additionalSourceProperties[.floor] as? Int
floorLabel.text = "Floor: \(location.additionalSourceProperties[.floor], default: "")"
positionSourceLabel.text = "Position source: \(location.additionalSourceProperties[.positionSource], default: "")"
transmitterCountLabel.text = "Transmitter count: \(location.additionalSourceProperties[.transmitterCount], default: "")"
horizontalAccuracyLabel.text = "Horizontal accuracy: \(numberFormatter.string(from: NSNumber(value: location.horizontalAccuracy)), default: "")"



let featureLayers = layers.filter{ $0 is AGSFeatureLayer} as! [AGSFeatureLayer]
featureLayers.forEach { featureLayer in
    featureLayer.definitionExpression = "VERTICAL_ORDER = \(currentFloor)"
}



private func setupLocationDisplay() {
    mapView.locationDisplay.dataSource = indoorLocationDataSource
    mapView.locationDisplay.autoPanMode = AGSLocationDisplayAutoPanMode.compassNavigation
    // Asynchronously start of the location display, which will in-turn start IndoorsLocationDataSource to start receiving IPS updates.
    mapView.locationDisplay.start { [weak self] (error) in
        guard let self = self, let error = error else { return }
        self.showError(error)
    }
}

Examples

Add indoor positioning to your app

To use indoor positioning in your ArcGIS Runtime app, you must have an IPS-enabled map. In addition to layers that describe the floor plan, an IPS-enabled map contains an IPS positioning table. See Get started with ArcGIS IPS in the ArcGIS Pro documentation for detailed instructions for setting up an IPS-enabled map.

In your app, make sure you request permissions for Bluetooth scanning. Also request permissions for accessing location (to use GPS).

Add this key to your app's Info.plist file.

Use dark colors for code blocks
    
1
2
3
4
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Bluetooth access is required for indoor positioning</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location services are required for GNSS positioning</string>
  1. Load the IPS-enabled map. This can be a web map hosted as a portal item in ArcGIS Online or an Enterprise Portal or a mobile map package (.mmpk) created with ArcGIS Pro.

  2. Create an AGSIndoorsLocationDataSource. Provide the positioning table (stored with the map) and the pathways feature class.

  3. Handle location change events if you want to respond to floor changes or read other metadata for locations.

  4. Assign the AGSIndoorsLocationDataSource to the map view's location display.

  5. Enable the map view's location display. Device location will appear on the display as a blue dot and update as the user moves throughout the space.

You can read the positioning table and pathways dataset from the map and use them to create the AGSIndoorsLocationDataSource.

Use dark colors for code blocks
                                                           
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

positioningTable.queryFeatures(with: queryParameters) { [weak self] result, error in
    guard let self = self else { return }

    guard let result = result, error == nil else {
        self.showError(IPSError.failedToLoadIPS)
        return
    }

    guard let feature = result.featureEnumerator().nextObject() else {
        self.showError(IPSError.noIPSDataFound)
        return
    }

    // The ID that identifies a row in the positioning table. AGSIndoorsLocationDataSource can be initialized without a row ID.
    guard let globalID = feature.attributes["GlobalID"] as? UUID else {
        self.showError(IPSError.mapDoesNotSupportIPS)
        return
    }

    let pathwaysTable = (map.operationalLayers as! [AGSFeatureLayer]).first(where: {$0.name == self.pathwaysTableName})?.featureTable as? AGSArcGISFeatureTable

    // Setting up AGSIndoorsLocationDataSource with positioning, pathways tables and positioning ID.
    // positioningTable - the "ips_positioning" feature table from an IPS-enabled map.
    // pathwaysTable - An AGSArcGISFeatureTable that contains pathways as per the ArcGIS Indoors Information Model.
    // Setting this property enables path snapping of locations provided by the AGSIndoorsLocationDataSource.
    // positioningID - an ID which identifies a specific row in the positioningTable that should be used for setting up IPS.
    self.indoorLocationDataSource = AGSIndoorsLocationDataSource(positioningTable: positioningTable, pathwaysTable: pathwaysTable, positioningID: globalID)
    // The delegate which will receive location, heading and status updates from the data source.
    self.indoorLocationDataSource?.locationChangeHandlerDelegate = self
    self.setupLocationDisplay()
}



currentFloor = location.additionalSourceProperties[.floor] as? Int
floorLabel.text = "Floor: \(location.additionalSourceProperties[.floor], default: "")"
positionSourceLabel.text = "Position source: \(location.additionalSourceProperties[.positionSource], default: "")"
transmitterCountLabel.text = "Transmitter count: \(location.additionalSourceProperties[.transmitterCount], default: "")"
horizontalAccuracyLabel.text = "Horizontal accuracy: \(numberFormatter.string(from: NSNumber(value: location.horizontalAccuracy)), default: "")"



let featureLayers = layers.filter{ $0 is AGSFeatureLayer} as! [AGSFeatureLayer]
featureLayers.forEach { featureLayer in
    featureLayer.definitionExpression = "VERTICAL_ORDER = \(currentFloor)"
}



private func setupLocationDisplay() {
    mapView.locationDisplay.dataSource = indoorLocationDataSource
    mapView.locationDisplay.autoPanMode = AGSLocationDisplayAutoPanMode.compassNavigation
    // Asynchronously start of the location display, which will in-turn start IndoorsLocationDataSource to start receiving IPS updates.
    mapView.locationDisplay.start { [weak self] (error) in
        guard let self = self, let error = error else { return }
        self.showError(error)
    }
}

Assign the AGSIndoorsLocationDataSource as the geoview's location display data source. Enable the location display to start receiving locations.

Use dark colors for code blocks
                                                           
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

positioningTable.queryFeatures(with: queryParameters) { [weak self] result, error in
    guard let self = self else { return }

    guard let result = result, error == nil else {
        self.showError(IPSError.failedToLoadIPS)
        return
    }

    guard let feature = result.featureEnumerator().nextObject() else {
        self.showError(IPSError.noIPSDataFound)
        return
    }

    // The ID that identifies a row in the positioning table. AGSIndoorsLocationDataSource can be initialized without a row ID.
    guard let globalID = feature.attributes["GlobalID"] as? UUID else {
        self.showError(IPSError.mapDoesNotSupportIPS)
        return
    }

    let pathwaysTable = (map.operationalLayers as! [AGSFeatureLayer]).first(where: {$0.name == self.pathwaysTableName})?.featureTable as? AGSArcGISFeatureTable

    // Setting up AGSIndoorsLocationDataSource with positioning, pathways tables and positioning ID.
    // positioningTable - the "ips_positioning" feature table from an IPS-enabled map.
    // pathwaysTable - An AGSArcGISFeatureTable that contains pathways as per the ArcGIS Indoors Information Model.
    // Setting this property enables path snapping of locations provided by the AGSIndoorsLocationDataSource.
    // positioningID - an ID which identifies a specific row in the positioningTable that should be used for setting up IPS.
    self.indoorLocationDataSource = AGSIndoorsLocationDataSource(positioningTable: positioningTable, pathwaysTable: pathwaysTable, positioningID: globalID)
    // The delegate which will receive location, heading and status updates from the data source.
    self.indoorLocationDataSource?.locationChangeHandlerDelegate = self
    self.setupLocationDisplay()
}



currentFloor = location.additionalSourceProperties[.floor] as? Int
floorLabel.text = "Floor: \(location.additionalSourceProperties[.floor], default: "")"
positionSourceLabel.text = "Position source: \(location.additionalSourceProperties[.positionSource], default: "")"
transmitterCountLabel.text = "Transmitter count: \(location.additionalSourceProperties[.transmitterCount], default: "")"
horizontalAccuracyLabel.text = "Horizontal accuracy: \(numberFormatter.string(from: NSNumber(value: location.horizontalAccuracy)), default: "")"



let featureLayers = layers.filter{ $0 is AGSFeatureLayer} as! [AGSFeatureLayer]
featureLayers.forEach { featureLayer in
    featureLayer.definitionExpression = "VERTICAL_ORDER = \(currentFloor)"
}



private func setupLocationDisplay() {
    mapView.locationDisplay.dataSource = indoorLocationDataSource
    mapView.locationDisplay.autoPanMode = AGSLocationDisplayAutoPanMode.compassNavigation
    // Asynchronously start of the location display, which will in-turn start IndoorsLocationDataSource to start receiving IPS updates.
    mapView.locationDisplay.start { [weak self] (error) in
        guard let self = self, let error = error else { return }
        self.showError(error)
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.