Calculate a viewshed using a geoprocessing service, in this case showing what parts of a landscape are visible from points on mountainous terrain.

Use case
A viewshed is used to highlight what is visible from a given point. A viewshed could be created to show what a hiker might be able to see from a given point at the top of a mountain. Equally, a viewshed could also be created from a point representing the maximum height of a proposed wind turbine to see from what areas the turbine would be visible.
Note: This analysis requires a geoprocessing service endpoint in a connected environment and processing times may vary depending on connection speeds. If fast, offline analysis is required, consider using a ViewshedFunction to perform a viewshed instead.
How to use the sample
Click the map to see all areas visible from that point within a 15km radius. Clicking on an elevated and unobstructed area will highlight a larger part of the surrounding landscape. It may take a few seconds for the task to run and send back the results.
How it works
- Create a
GeoprocessingTaskobject with the URL set to a geoprocessing service endpoint. - Create a
FeatureCollectionTableobject and add a newFeatureobject whose geometry is the viewshed’s observerPoint. - Make a
GeoprocessingParametersand pass in theGeoprocessingFeaturestable which contains the observation point as aninputparameter. - Use the geoprocessing task to create a
GeoprocessingJobobject with the parameters. - Start the job and wait for it to complete and return a
GeoprocessingResultobject. - Get the resulting
GeoprocessingFeaturesobject. - Iterate through the viewshed
GeoprocessingFeaturesto use their geometry or display the geometry in a newGraphicobject.
Relevant API
- FeatureCollectionTable
- GeoprocessingFeatures
- GeoprocessingJob
- GeoprocessingParameters
- GeoprocessingResult
- GeoprocessingTask
Tags
geoprocessing, heat map, heatmap, viewshed
Sample Code
/* Copyright 2025 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.showviewshedcalculatedfromgeoprocessingtask
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.showviewshedcalculatedfromgeoprocessingtask.screens.MainScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN)
enableEdgeToEdge() setContent { SampleAppTheme { ShowViewshedCalculatedFromGeoprocessingTaskApp() } } }
@Composable private fun ShowViewshedCalculatedFromGeoprocessingTaskApp() { Surface(color = MaterialTheme.colorScheme.background) { MainScreen( sampleName = getString(R.string.show_viewshed_calculated_from_geoprocessing_task_app_name) ) } }}/* Copyright 2025 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.showviewshedcalculatedfromgeoprocessingtask.components
import android.app.Applicationimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.data.FeatureCollectionTableimport com.arcgismaps.geometry.GeodeticCurveTypeimport com.arcgismaps.geometry.GeometryEngineimport com.arcgismaps.geometry.GeometryTypeimport com.arcgismaps.geometry.LinearUnitimport com.arcgismaps.geometry.Pointimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.symbology.SimpleFillSymbolimport com.arcgismaps.mapping.symbology.SimpleFillSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleRendererimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.SingleTapConfirmedEventimport com.arcgismaps.tasks.geoprocessing.GeoprocessingExecutionTypeimport com.arcgismaps.tasks.geoprocessing.GeoprocessingJobimport com.arcgismaps.tasks.geoprocessing.GeoprocessingParametersimport com.arcgismaps.tasks.geoprocessing.GeoprocessingTaskimport com.arcgismaps.tasks.geoprocessing.geoprocessingparameters.GeoprocessingFeaturesimport com.arcgismaps.tasks.geoprocessing.geoprocessingparameters.GeoprocessingLinearUnitimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.launch
class MapViewModel(application: Application) : AndroidViewModel(application) {
// ArcGISMap with a topographic basemap val arcGISMap: ArcGISMap = ArcGISMap(BasemapStyle.ArcGISTopographic).apply { initialViewpoint = Viewpoint( latitude = 45.379, longitude = 6.849, scale = 144447.0 ) }
// Used by the composable MapView for viewpoint changes val mapviewProxy = MapViewProxy()
// Graphics overlay for the red marker at the tapped location val inputGraphicsOverlay = GraphicsOverlay().apply { renderer = SimpleRenderer( symbol = SimpleMarkerSymbol( style = SimpleMarkerSymbolStyle.Circle, color = Color.red, size = 10f ) ) }
// Graphics overlay for displaying the resulting viewshed polygons val resultGraphicsOverlay = GraphicsOverlay().apply { renderer = SimpleRenderer( symbol = SimpleFillSymbol( style = SimpleFillSymbolStyle.Solid, color = Color.fromRgba(r = 255, g = 165, b = 0, a = 100) ) ) }
// Graphics overlay for displaying the 15 km buffer range val bufferGraphicsOverlay = GraphicsOverlay().apply { renderer = SimpleRenderer( symbol = SimpleFillSymbol( style = SimpleFillSymbolStyle.Solid, color = Color.fromRgba(r = 0, g = 0, b = 255, a = 50) ) ) }
// GeoprocessingTask pointing to the Viewshed service URL private val geoprocessingTask = GeoprocessingTask( url = "https://sampleserver6.arcgisonline.com/arcgis/rest/services/Elevation/ESRI_Elevation_World/GPServer/Viewshed" )
// Running GeoprocessingJob for cancellation/cleanup private var geoprocessingJob: GeoprocessingJob? = null
// State flows for controlling UI private val _isGeoprocessingInProgress = MutableStateFlow(false) val isGeoprocessingInProgress = _isGeoprocessingInProgress.asStateFlow()
// Message dialog view model for handling error messages val messageDialogVM = MessageDialogViewModel()
init { viewModelScope.launch { arcGISMap.load().onFailure { messageDialogVM.showMessageDialog(it) } } }
/** * Handles the [singleTapConfirmedEvent] by retrieving the tapped [Point] to * cancel and run a new viewshed geoprocessing job. */ fun onMapTapped(singleTapConfirmedEvent: SingleTapConfirmedEvent) { val tapPoint = singleTapConfirmedEvent.mapPoint ?: return messageDialogVM.showMessageDialog("Unable to retrieve tapped point") viewModelScope.launch { // Clear existing overlays and cancel any running job clearOverlays() geoprocessingJob?.cancel() // Add a new red marker to the map at the tapped point addTapMarker(tapPoint) // Show a 15 km buffer to visualize the visibility range addBufferGraphic(tapPoint) // Start the geoprocessing job to obtain the viewshed polygons _isGeoprocessingInProgress.value = true calculateViewshed(tapPoint) _isGeoprocessingInProgress.value = false } }
/** * Perform the viewshed calculation on the geoprocessing service * for the given [tapPoint]. */ private suspend fun calculateViewshed(tapPoint: Point) { // Create an empty FeatureCollectionTable for the tapped location val table = FeatureCollectionTable( fields = emptyList(), geometryType = GeometryType.Point, spatialReference = tapPoint.spatialReference )
// Create a new feature with the tapped geometry and add to the table val newFeature = table.createFeature().also { it.geometry = tapPoint } table.addFeature(newFeature)
// Create geoprocessing parameters for a synchronous execution val geoprocessingParameters = GeoprocessingParameters( geoprocessingExecutionType = GeoprocessingExecutionType.SynchronousExecute ).apply { processSpatialReference = tapPoint.spatialReference outputSpatialReference = tapPoint.spatialReference // Provide the tapped point as "Input_Observation_Point" inputs["Input_Observation_Point"] = GeoprocessingFeatures(featureSet = table) inputs["Viewshed_Distance"] = GeoprocessingLinearUnit(distance = 15000.0) }
// Create a new job geoprocessingJob = geoprocessingTask.createJob(geoprocessingParameters)
// Start and await the result geoprocessingJob?.start()
val gpResult = geoprocessingJob?.result()?.getOrElse { return messageDialogVM.showMessageDialog(it) }
// Get the output features for the viewshed polygon val viewshedFeatureSet = gpResult?.outputs?.get("Viewshed_Result") as? GeoprocessingFeatures ?: return messageDialogVM.showMessageDialog("No viewshed result found in the geoprocessing job.") val featureSet = viewshedFeatureSet.features ?: return messageDialogVM.showMessageDialog("Geoprocessing feature set is null.")
// Add each resulting feature geometry as a graphic to resultGraphicsOverlay val resultGraphics = featureSet.mapNotNull { feature -> feature.geometry?.let { Graphic(it) } }
// Add the graphics to the overlay and set the map's viewpoint to its extent resultGraphicsOverlay.graphics.addAll(resultGraphics) resultGraphicsOverlay.extent?.let { resultExtent -> mapviewProxy.setViewpointGeometry( boundingGeometry = resultExtent, paddingInDips = 20.0 ) } }
/** * Place a simple red marker graphic at the tapped location. */ private fun addTapMarker(tapPoint: Point) { val graphic = Graphic(tapPoint) inputGraphicsOverlay.graphics.add(graphic) }
/** * Add a 15 km buffer graphic around [tapPoint]. */ private fun addBufferGraphic(tapPoint: Point, radiusMeters: Double = 15000.0) { // Use the geometry engine to build a geodesic planar buffer val bufferGeometry = GeometryEngine.bufferGeodeticOrNull( geometry = tapPoint, distance = radiusMeters, distanceUnit = LinearUnit.meters, maxDeviation = Double.NaN, curveType = GeodeticCurveType.Geodesic ) // Create a graphic from the buffered geometry val bufferGraphic = Graphic(bufferGeometry) // Add it to the buffer graphics overlay bufferGraphicsOverlay.graphics.add(bufferGraphic) }
/** * Clear any previous marker or result polygons from the map. */ private fun clearOverlays() { inputGraphicsOverlay.graphics.clear() resultGraphicsOverlay.graphics.clear() bufferGraphicsOverlay.graphics.clear() }}/* Copyright 2025 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.showviewshedcalculatedfromgeoprocessingtask.screens
import androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.Scaffoldimport androidx.compose.runtime.Composableimport androidx.compose.runtime.collectAsStateimport androidx.compose.runtime.getValueimport androidx.compose.ui.Modifierimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.sampleslib.components.LoadingDialogimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport com.esri.arcgismaps.sample.showviewshedcalculatedfromgeoprocessingtask.components.MapViewModel
/** * Main screen layout for the sample app */@Composablefun MainScreen(sampleName: String) { val mapViewModel: MapViewModel = viewModel()
// Observe geoprocessing state from the view model val isGeoprocessingInProgress by mapViewModel.isGeoprocessingInProgress.collectAsState()
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { Column( modifier = Modifier .fillMaxSize() .padding(it), ) { MapView( modifier = Modifier .fillMaxSize() .weight(1f), arcGISMap = mapViewModel.arcGISMap, onSingleTapConfirmed = mapViewModel::onMapTapped, mapViewProxy = mapViewModel.mapviewProxy, graphicsOverlays = listOf( mapViewModel.inputGraphicsOverlay, mapViewModel.bufferGraphicsOverlay, mapViewModel.resultGraphicsOverlay ) ) }
mapViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } }
// If geoprocessing is running, show a loading dialog if (isGeoprocessingInProgress) { LoadingDialog(loadingMessage = "Calculating viewshed…") } } )}