Analyze terrain suitability from an elevation raster by deriving slope and aspect.

Use case
Terrain suitability analysis is a common way to narrow a larger elevation surface down to areas that match a specific set of conditions. Slope and aspect are derived from elevation datasets to show how steep the terrain is and which direction it faces. Both of these factors can determine whether an area is suitable for a given purpose, for example, finding areas which are more sheltered from weather versus areas with more exposed terrain.
How to use the sample
When the sample opens, the map shows the results of a preconfigured terrain suitability analysis which finds southward facing lowland slopes on the Isle of Arran, Scotland. The areas matching the criteria are rendered in green, and those not, in white. Open the settings panel to choose another preconfigured scenario, that of a west to north facing slope in upland terrains. Areas matching these criteria are rendered in purple.
How it works
- Create a
ContinuousFieldfrom a raster file. - Create a
ContinuousFieldFunctionfrom the continuous field. - Derive a
slopefunction and anaspectfunction from the continuous field function. - Create
BooleanFieldFunctionmasks for slope, aspect, and elevation using range checks with map algebra. - Combine the masks using the infix
andfunction and apply a land-only mask to exclude areas below sea level. - Create a
FieldAnalysisfrom the resultantBooleanFieldFunction. - Apply a
ColormapRendererwith a color for areas not matching the terrain suitability criteria, and a color for matching areas. - Add the analysis to an
AnalysisOverlay.
Relevant API
- AnalysisOverlay
- BooleanFieldFunction
- Colormap
- ColormapRenderer
- ContinuousField
- ContinuousFieldFunction
- FieldAnalysis
About the data
The sample uses a 10m resolution digital terrain elevation raster of the Isle of Arran, Scotland (Data Copyright Scottish Government and SEPA (2014)).
Tags
aspect, elevation, field analysis, map algebra, raster, slope, spatial reference, terrain
Sample code
/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components
import android.app.Applicationimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.analysis.BooleanFieldFunctionimport com.arcgismaps.analysis.ContinuousFieldimport com.arcgismaps.analysis.ContinuousFieldFunctionimport com.arcgismaps.analysis.interactive.FieldAnalysisimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.symbology.raster.Colormapimport com.arcgismaps.mapping.symbology.raster.ColormapRendererimport com.arcgismaps.mapping.view.AnalysisOverlayimport com.arcgismaps.mapping.view.AnalysisViewStatusimport com.arcgismaps.mapping.view.GeoViewimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.Rimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.flow.updateimport kotlinx.coroutines.launchimport java.io.File
class AnalyzeTerrainSuitabilityFromSlopeAndAspectViewModel(app: Application) : AndroidViewModel(app) { // Create a state flow to hold the UI state for the supporting pane controls private val _slopeAspectUiState = MutableStateFlow(SlopeAspectUiState.defaultState)
// Expose the state flow as read-only for the UI val adaptiveUiState = _slopeAspectUiState.asStateFlow()
// Create a MapViewProxy, used to set viewpoint val mapViewProxy = MapViewProxy()
// Initialize and keep track of the ArcGISMap & the AnalysisOverlay val arcGISMap = ArcGISMap(SpatialReference(wkid = 32630)) // UTM30N spatial reference val analysisOverlay = AnalysisOverlay()
// Indicates when the progress indicator should be displayed var displayProgressIndicator by mutableStateOf(false) private set
// Create a message dialog view model for handling error messages val messageDialogVM = MessageDialogViewModel()
// Location of file containing elevation data private val provisionPath: String by lazy { app.getExternalFilesDir(null)?.path + File.separator + app.getString( R.string.analyze_terrain_suitability_from_slope_and_aspect_app_name ) + File.separator } private val filePath = provisionPath + "arran.tif"
// FieldAnalysis objects for Sheltered and Exposed analysis scenarios private var shelteredSlopesAnalysis: FieldAnalysis? = null private var exposedSlopesAnalysis: FieldAnalysis? = null
init { viewModelScope.launch { // Create a ContinuousField from a raster file containing elevation data and project it // to the UTM30N spatial reference ContinuousField.createFromFiles( filePaths = listOf(filePath), band = 0, spatialReference = SpatialReference(wkid = 32630) ).onFailure { messageDialogVM.showMessageDialog(it) }.onSuccess { continuousField -> // Center the MapView on the data we have mapViewProxy.setViewpointCenter( center = continuousField.extent.center, scale = 200000.0 )
// Create the continuous field function for the elevation data val elevationFieldFunction = ContinuousFieldFunction.create(result = continuousField)
// Derive slope and aspect from the elevation field val slopeFunction = elevationFieldFunction.slope() val aspectFunction = elevationFieldFunction.aspect()
// Keep only land areas above sea level val aboveSeaLevelSelection = elevationFieldFunction.isGreaterThanOrEqualTo(0f)
// Create FieldAnalysis objects for the 2 scenarios to be shown shelteredSlopesAnalysis = createScenarioAnalysis( slopeFunction = slopeFunction, aspectFunction = aspectFunction, elevationFieldFunction = elevationFieldFunction, aboveSeaLevelSelection = aboveSeaLevelSelection, slopeMin = 0f, // flat terrain slopeMax = 20f, // moderate slopes aspectStart = 112.5f, // east-south-east facing aspect aspectEnd = 247.5f, // west-south-west facing aspect elevationMin = 0f, elevationMax = 300f, // avoid higher elevations color = Color.fromRgba(r = 0, g = 180, b = 0, a = 255) ) exposedSlopesAnalysis = createScenarioAnalysis( slopeFunction = slopeFunction, aspectFunction = aspectFunction, elevationFieldFunction = elevationFieldFunction, aboveSeaLevelSelection = aboveSeaLevelSelection, slopeMin = 20f, // moderate slopes slopeMax = 80f, // very steep slopes aspectStart = 202.5f, // south-south-west facing aspect aspectEnd = 67.5f, // east-north-east facing aspect elevationMin = 300f, elevationMax = 850f, // higher elevations more exposed color = Color.fromRgba(r = 180, g = 0, b = 180, a = 255) )
// Display the progress indicator and make the initially selected scenario // visible; calculation of the analysis starts when it is made visible displayProgressIndicator = true _slopeAspectUiState.value.scenarioOption.let { selectedOption -> shelteredSlopesAnalysis?.isVisible = selectedOption == ScenarioOption.Sheltered exposedSlopesAnalysis?.isVisible = selectedOption == ScenarioOption.Exposed } } } }
/** * Creates a FieldAnalysis for a given scenario based on slope, aspect, and elevation ranges. * The FieldAnalysis is added to the AnalysisOverlay, but its visibility is set false so it * won't be displayed yet. */ private fun createScenarioAnalysis( slopeFunction: ContinuousFieldFunction, aspectFunction: ContinuousFieldFunction, elevationFieldFunction: ContinuousFieldFunction, aboveSeaLevelSelection: BooleanFieldFunction, slopeMin: Float, slopeMax: Float, aspectStart: Float, aspectEnd: Float, elevationMin: Float, elevationMax: Float, color: Color ): FieldAnalysis { // Create a BooleanFieldFunction for the scenario val scenarioFieldFunction = createScenarioFieldFunction( slopeFunction = slopeFunction, aspectFunction = aspectFunction, elevationFieldFunction = elevationFieldFunction, aboveSeaLevelSelection = aboveSeaLevelSelection, slopeMin = slopeMin, slopeMax = slopeMax, aspectStart = aspectStart, aspectEnd = aspectEnd, elevationMin = elevationMin, elevationMax = elevationMax )
// Create a colormap and renderer to display the results; white for areas that don't match // the scenario results, and the given color for those that do val colormapRenderer = ColormapRenderer( colormap = Colormap.create(colors = listOf(Color.white, color)) )
// Create the FieldAnalysis, set its visibility to false, and add it to the AnalysisOverlay val analysis = FieldAnalysis( booleanFieldFunction = scenarioFieldFunction, colormapRenderer = colormapRenderer ) analysis.isVisible = false analysisOverlay.analyses.add(analysis) return analysis }
/** * Creates a BooleanFieldFunction for a given scenario based on slope, aspect, and elevation * ranges. */ private fun createScenarioFieldFunction( slopeFunction: ContinuousFieldFunction, aspectFunction: ContinuousFieldFunction, elevationFieldFunction: ContinuousFieldFunction, aboveSeaLevelSelection: BooleanFieldFunction, slopeMin: Float, slopeMax: Float, aspectStart: Float, aspectEnd: Float, elevationMin: Float, elevationMax: Float, ): BooleanFieldFunction { // Create BooleanFieldFunctions for slope, aspect and elevation that assign pixels a value // of 1 when within the range of values provided for the scenario, and 0 when outside the // range. Note that `and` and `or` functions can be called using the infix notation. val slopeMinMask = slopeFunction.isGreaterThanOrEqualTo(slopeMin) val slopeMaxMask = slopeFunction.isLessThanOrEqualTo(slopeMax) val slopeRangeMask = slopeMinMask and slopeMaxMask
val elevationMinMask = elevationFieldFunction.isGreaterThanOrEqualTo(elevationMin) val elevationMaxMask = elevationFieldFunction.isLessThanOrEqualTo(elevationMax) val elevationRangeMask = elevationMinMask and elevationMaxMask
// Handle the case where the aspect range crosses the 0-degree line (e.g. 225 to 45 degrees) val aspectRangeMask = if (aspectStart <= aspectEnd) { val aspectMinMask = aspectFunction.isGreaterThanOrEqualTo(aspectStart) val aspectMaxMask = aspectFunction.isLessThanOrEqualTo(aspectEnd) aspectMinMask and aspectMaxMask } else { val upperAspectMinMask = aspectFunction.isGreaterThanOrEqualTo(aspectStart) val upperAspectMaxMask = aspectFunction.isLessThan(360f) val upperAspectBandMask = upperAspectMinMask and upperAspectMaxMask
val lowerAspectMinMask = aspectFunction.isGreaterThanOrEqualTo(0f) val lowerAspectMaxMask = aspectFunction.isLessThanOrEqualTo(aspectEnd) val lowerAspectBandMask = lowerAspectMinMask and lowerAspectMaxMask
upperAspectBandMask or lowerAspectBandMask }
// Combine the slope, aspect, and elevation masks with the land-only aboveSeaLevelSelection // to create a final BooleanFieldFunction for the scenario val scenarioRangeMask = slopeRangeMask and aspectRangeMask and elevationRangeMask return scenarioRangeMask.mask(aboveSeaLevelSelection) }
/** * An AnalysisViewStatus listener that displays the progress indicator when the status of the * current scenario analysis is Updating and hides it when not Updating. * Also displays details of any error that occurs when the analysis is displayed. */ fun analysisViewStatusListener(event: GeoView.GeoViewAnalysisViewStatusChanged) { displayProgressIndicator = when (event.analysisViewStatus){ is AnalysisViewStatus.Error -> { messageDialogVM.showMessageDialog( throwable = (event.analysisViewStatus as AnalysisViewStatus.Error).details ) false } AnalysisViewStatus.UpToDate -> { false } AnalysisViewStatus.Updating -> { true } } }
/** * Updates the currently selected ScenarioOption. */ fun updateScenarioOption(selectedScenarioOption: ScenarioOption) { // Update the UI state _slopeAspectUiState.update { currentState -> currentState.copy(scenarioOption = selectedScenarioOption) }
// Keep only the selected scenario analysis visible. _slopeAspectUiState.value.scenarioOption.let { selectedOption -> shelteredSlopesAnalysis?.isVisible = selectedOption == ScenarioOption.Sheltered exposedSlopesAnalysis?.isVisible = selectedOption == ScenarioOption.Exposed } }}
data class SlopeAspectUiState(val scenarioOption: ScenarioOption) { companion object { val defaultState = SlopeAspectUiState( scenarioOption = ScenarioOption.Sheltered ) }}
enum class ScenarioOption { Sheltered, Exposed}/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.screens.AnalyzeTerrainSuitabilityFromSlopeAndAspectScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { SampleAppTheme { Surface(color = MaterialTheme.colorScheme.background) { AnalyzeTerrainSuitabilityFromSlopeAndAspectScreen() } } } }}/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */package com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect
import android.content.Intentimport android.os.Bundleimport com.esri.arcgismaps.sample.sampleslib.DownloaderActivity
class DownloadActivity : DownloaderActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) downloadAndStartSample( mainActivity = Intent(this, MainActivity::class.java), sampleName = getString(R.string.analyze_terrain_suitability_from_slope_and_aspect_app_name), provisionURLs = listOf( "https://www.arcgis.com/home/item.html?id=aa97788593e34a32bcaae33947fdc271" ) ) }}/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.screens
import androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.ButtonDefaultsimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.RadioButtonimport androidx.compose.material3.Surfaceimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.style.TextAlignimport androidx.compose.ui.unit.dpimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components.SlopeAspectUiStateimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components.ScenarioOption
/** * Supporting pane content for the sample. */@Composableinternal fun AnalyzeTerrainSuitabilityFromSlopeAndAspectSupportingPane( slopeAspectUiState: SlopeAspectUiState, onSelectionChange: (ScenarioOption) -> Unit) { Text( text = "Sheltered vs exposed terrain suitability", style = MaterialTheme.typography.titleMedium ) ScenarioOption.entries.forEach { mode -> SelectionRow( title = mode.name, description = when (mode) { ScenarioOption.Sheltered -> { "Gentle, lowland south-facing slopes" }
ScenarioOption.Exposed -> { "Steep, upland slopes facing west through north" } }, selected = slopeAspectUiState.scenarioOption == mode, onClick = { onSelectionChange(mode) } ) } RasterDataCopyrightText()}
/** * Reusable composable for a row with a title, description, and a radio button to indicate selection. */@Composableprivate fun SelectionRow( title: String, description: String, selected: Boolean, onClick: () -> Unit,) { Surface( modifier = Modifier.fillMaxWidth(), onClick = onClick, shape = RoundedCornerShape(12.dp), color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, border = ButtonDefaults.outlinedButtonBorder(enabled = true) ) { Row( modifier = Modifier .fillMaxWidth() .padding(6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = selected, onClick = onClick ) Column { Text( text = title, style = MaterialTheme.typography.bodyLarge ) Text( text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } }}
/** * Displays copyright text for the sample's source raster. */@Composablefun RasterDataCopyrightText() { Text( text = "Raster data copyright Scottish Government and SEPA (2014)", style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() )}/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.screens
import androidx.compose.animation.animateContentSizeimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.BoxScopeimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.Scaffoldimport androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.Colorimport com.arcgismaps.mapping.view.BackgroundGridimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.Rimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components.SlopeAspectUiStateimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components.ScenarioOptionimport com.esri.arcgismaps.sample.analyzeterrainsuitabilityfromslopeandaspect.components.AnalyzeTerrainSuitabilityFromSlopeAndAspectViewModelimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleDeviceLightDarkPreviewimport com.esri.arcgismaps.sample.sampleslib.components.SamplePreviewSurfaceimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport com.esri.arcgismaps.sample.sampleslib.components.adaptive.AdaptiveThreePaneimport com.esri.arcgismaps.sample.sampleslib.components.adaptive.ThreePaneConfig
/** * Main composable screen for the sample. * It owns the ViewModel and hoists UI state for the scaffold and the MapView. */@Composablefun AnalyzeTerrainSuitabilityFromSlopeAndAspectScreen( viewModel: AnalyzeTerrainSuitabilityFromSlopeAndAspectViewModel = viewModel()) { val adaptiveUiState = viewModel.adaptiveUiState.collectAsStateWithLifecycle().value
MainScreenScaffold( slopeAspectUiState = adaptiveUiState, onSelectionChange = viewModel::updateScenarioOption, mainPaneContent = { MapView( modifier = Modifier .fillMaxSize() .animateContentSize(), arcGISMap = viewModel.arcGISMap, mapViewProxy = viewModel.mapViewProxy, analysisOverlays = listOf(viewModel.analysisOverlay), backgroundGrid = BackgroundGrid(color = Color.lightGray), onAnalysisViewStatusChanged = viewModel::analysisViewStatusListener ) if (viewModel.displayProgressIndicator) { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator() } } } )
viewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } }}
@Composableprivate fun MainScreenScaffold( slopeAspectUiState: SlopeAspectUiState, onSelectionChange: (ScenarioOption) -> Unit = {}, mainPaneContent: @Composable BoxScope.() -> Unit) { Scaffold( topBar = { SampleTopAppBar( title = stringResource(R.string.analyze_terrain_suitability_from_slope_and_aspect_app_name) ) }, content = { paddingValues -> AdaptiveThreePane( modifier = Modifier .fillMaxSize() .padding(paddingValues), config = ThreePaneConfig(compactSupportingPaneHeightRatio = 0.37f), supportingPaneTitle = "Analysis options", mainPane = { _, _ -> mainPaneContent() }, supportingPane = { _, _ -> AnalyzeTerrainSuitabilityFromSlopeAndAspectSupportingPane( slopeAspectUiState = slopeAspectUiState, onSelectionChange = onSelectionChange ) } ) } )}
@SampleDeviceLightDarkPreview@Composablefun MainScreenPreview() { SamplePreviewSurface { MainScreenScaffold( slopeAspectUiState = SlopeAspectUiState.defaultState, mainPaneContent = {} ) }}