Perform an exploratory viewshed analysis from a defined vantage point.

Use case
An exploratory viewshed analysis is a type of visual analysis you can perform at the current rendered resolution of a scene. The exploratory viewshed shows what can be seen from a given location. The output is an overlay with two different colors - one representing the visible areas (green) and the other representing the obstructed areas (red).
Note: This analysis is a form of “exploratory analysis”, which means the results are calculated on the current scale of the data, and the results are generated very quickly but not persisted. If persisted analysis performed at the full resolution of the data is required, consider using a ViewshedFunction to perform a viewshed calculation instead.
How to use the sample
- Use the supporting pane sliders to change heading, pitch, horizontal and vertical angles, and minimum/maximum distances.
- Open the scene options floating pane to toggle frustum outline and analysis overlay visibility.
- Use scene option actions to align the camera with the viewshed or reset all viewshed options.
How it works
- Create an
ExploratoryLocationViewshedpassing in the observer location, heading, pitch, horizontal/vertical angles, and min/max distances. - Set the property values on the exploratory viewshed instance for location, direction, range, and visibility properties.
Relevant API
- AnalysisOverlay
- ArcGISSceneLayer
- ArcGISTiledElevationSource
- ExploratoryLocationViewshed
- ExploratoryViewshed
About the data
The scene shows a buildings layer in Brest, France hosted on ArcGIS Online.
Additional information
This sample uses the GeoView-Compose Toolkit module to be able to implement a composable SceneView.
Tags
3D, exploratory viewshed, frustum, geoview-compose, scene, visibility analysis
Sample code
/* Copyright 2023 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.showexploratoryviewshedfrompointinscene.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.analysis.interactive.ExploratoryLocationViewshedimport com.arcgismaps.geometry.Pointimport com.arcgismaps.mapping.ArcGISSceneimport com.arcgismaps.mapping.ArcGISTiledElevationSourceimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Surfaceimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.layers.ArcGISSceneLayerimport com.arcgismaps.mapping.view.AnalysisOverlayimport com.arcgismaps.mapping.view.AnalysisViewStatusimport com.arcgismaps.mapping.view.Cameraimport com.arcgismaps.mapping.view.GeoViewimport com.arcgismaps.toolkit.geoviewcompose.SceneViewProxyimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport com.esri.arcgismaps.sample.showexploratoryviewshedfrompointinscene.Rimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.flow.updateimport kotlinx.coroutines.launchimport kotlin.time.Duration.Companion.seconds
class SceneViewModel(private val application: Application) : AndroidViewModel(application) {
// initialize location viewshed parameters private var viewshed: ExploratoryLocationViewshed private val initHeading = 82.0 private val initPitch = 60.0 private val initHorizontalAngle = 75.0 private val initVerticalAngle = 90.0 private val initMinDistance = 0.0 private val initMaxDistance = 1500.0 private val initFrustumVisible = true private val initAnalysisVisible = true
private val initViewshedUiState = ViewshedUiState( heading = initHeading.toFloat(), pitch = initPitch.toFloat(), horizontalAngle = initHorizontalAngle.toFloat(), verticalAngle = initVerticalAngle.toFloat(), minDistance = initMinDistance.toFloat(), maxDistance = initMaxDistance.toFloat(), isFrustumVisible = initFrustumVisible, isAnalysisVisible = initAnalysisVisible )
val initLocation = Point( x = -4.50, y = 48.4, z = 1000.0 )
private val initialCamera = Camera( lookAtPoint = initLocation, distance = 3500.0, heading = 50.0, pitch = 70.0, roll = 0.0 ) var scene by mutableStateOf(ArcGISScene(BasemapStyle.ArcGISNavigationNight)) var analysisOverlay by mutableStateOf(AnalysisOverlay())
private val _viewshedUiState = MutableStateFlow(initViewshedUiState) val viewshedUiState = _viewshedUiState.asStateFlow()
val sceneViewProxy = SceneViewProxy()
// Message dialog view model to display errors val messageDialogVM = MessageDialogViewModel()
init { // create a surface for elevation data val surface = Surface().apply { elevationSources.add(ArcGISTiledElevationSource(application.getString(R.string.elevation_service))) }
// create a layer of buildings val buildingsSceneLayer = ArcGISSceneLayer(application.getString(R.string.buildings_layer))
// create a scene and add imagery basemap, elevation surface, and buildings layer to it val buildingsScene = ArcGISScene(BasemapStyle.ArcGISImagery).apply { baseSurface = surface operationalLayers.add(buildingsSceneLayer) }
// create viewshed from the initial location viewshed = ExploratoryLocationViewshed( location = initLocation, heading = initHeading, pitch = initPitch, horizontalAngle = initHorizontalAngle, verticalAngle = initVerticalAngle, minDistance = initMinDistance, maxDistance = initMaxDistance ).apply { frustumOutlineVisible = initFrustumVisible }
// add the buildings scene to the sceneView scene = buildingsScene.apply { baseSurface = surface initialViewpoint = Viewpoint(initLocation, initialCamera) } // add the viewshed to the analysisOverlay of the scene view analysisOverlay.apply { analyses.add(viewshed) isVisible = initAnalysisVisible } }
fun setHeading(sliderHeading: Float) { viewshed.heading = sliderHeading.toDouble() _viewshedUiState.update { it.copy(heading = sliderHeading) } }
fun setMaximumDistance(sliderValue: Float) { viewshed.maxDistance = sliderValue.toDouble() _viewshedUiState.update { it.copy(maxDistance = sliderValue) } }
fun setMinimumDistance(sliderValue: Float) { viewshed.minDistance = sliderValue.toDouble() _viewshedUiState.update { it.copy(minDistance = sliderValue) } }
fun setVerticalAngle(sliderValue: Float) { viewshed.verticalAngle = sliderValue.toDouble() _viewshedUiState.update { it.copy(verticalAngle = sliderValue) } }
fun setHorizontalAngle(sliderValue: Float) { viewshed.horizontalAngle = sliderValue.toDouble() _viewshedUiState.update { it.copy(horizontalAngle = sliderValue) } }
fun setPitch(sliderValue: Float) { viewshed.pitch = sliderValue.toDouble() _viewshedUiState.update { it.copy(pitch = sliderValue) } }
fun setFrustumVisibility(checkedValue: Boolean) { viewshed.frustumOutlineVisible = checkedValue _viewshedUiState.update { it.copy(isFrustumVisible = checkedValue) } }
fun setAnalysisVisibility(checkedValue: Boolean) { viewshed.isVisible = checkedValue _viewshedUiState.update { it.copy(isAnalysisVisible = checkedValue) } }
fun resetViewshedOptions() { viewshed.apply { heading = initHeading pitch = initPitch horizontalAngle = initHorizontalAngle verticalAngle = initVerticalAngle minDistance = initMinDistance maxDistance = initMaxDistance frustumOutlineVisible = initFrustumVisible isVisible = initAnalysisVisible } _viewshedUiState.value = initViewshedUiState viewModelScope.launch { sceneViewProxy.setViewpointCameraAnimated( camera = initialCamera, duration = 1.seconds ) } }
/** * Animates the camera to a meaningful overview centered on the viewshed analysis location. */ fun setViewpointToAnalysisExtent() { viewModelScope.launch { sceneViewProxy.setViewpointCameraAnimated( camera = Camera( lookAtPoint = viewshed.location, distance = viewshed.maxDistance ?: initMaxDistance, heading = viewshed.heading, pitch = viewshed.pitch, roll = 0.0 ), duration = 2.seconds ) } }
/** * Display dialog if there is an error with analysis. */ fun analysisViewStatusListener(event: GeoView.GeoViewAnalysisViewStatusChanged) { if (event.analysisViewStatus is AnalysisViewStatus.Error) { messageDialogVM.showMessageDialog( throwable = (event.analysisViewStatus as AnalysisViewStatus.Error).details ) } }}
data class ViewshedUiState( val heading: Float, val pitch: Float, val horizontalAngle: Float, val verticalAngle: Float, val minDistance: Float, val maxDistance: Float, val isFrustumVisible: Boolean, val isAnalysisVisible: Boolean)/* Copyright 2023 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.showexploratoryviewshedfrompointinscene
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.showexploratoryviewshedfrompointinscene.screens.MainScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
enableEdgeToEdge() setContent { SampleAppTheme { Surface(color = MaterialTheme.colorScheme.background) { MainScreen() } } } }}/* Copyright 2023 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.showexploratoryviewshedfrompointinscene.screens
import androidx.compose.animation.animateContentSizeimport androidx.compose.foundation.layout.BoxScopeimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.Scaffoldimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.SceneViewimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.adaptive.AdaptiveThreePaneimport 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.showexploratoryviewshedfrompointinscene.Rimport com.esri.arcgismaps.sample.showexploratoryviewshedfrompointinscene.components.SceneViewModelimport com.esri.arcgismaps.sample.showexploratoryviewshedfrompointinscene.components.ViewshedUiState
/** * Main screen layout for the sample app */@Composablefun MainScreen() { // create a ViewModel to handle SceneView interactions val sceneViewModel: SceneViewModel = viewModel() val viewshedUiState by sceneViewModel.viewshedUiState.collectAsStateWithLifecycle()
MainScreenScaffold( viewshedUiState = viewshedUiState, onHeadingChanged = sceneViewModel::setHeading, onPitchChanged = sceneViewModel::setPitch, onHorizontalAngleChanged = sceneViewModel::setHorizontalAngle, onVerticalAngleChanged = sceneViewModel::setVerticalAngle, onMinDistanceChanged = sceneViewModel::setMinimumDistance, onMaxDistanceChanged = sceneViewModel::setMaximumDistance, onFrustumVisibilityChanged = sceneViewModel::setFrustumVisibility, onAnalysisVisibilityChanged = sceneViewModel::setAnalysisVisibility, onSetViewpointToAnalysisExtent = sceneViewModel::setViewpointToAnalysisExtent, onResetViewshedOptions = sceneViewModel::resetViewshedOptions, mainPaneContent = { SceneView( modifier = Modifier .fillMaxSize() .animateContentSize(), arcGISScene = sceneViewModel.scene, sceneViewProxy = sceneViewModel.sceneViewProxy, analysisOverlays = listOf(sceneViewModel.analysisOverlay), onAnalysisViewStatusChanged = sceneViewModel::analysisViewStatusListener ) // Show a message dialog if the viewmodel reported an error sceneViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } )}
@Composableprivate fun MainScreenScaffold( viewshedUiState: ViewshedUiState, onHeadingChanged: (Float) -> Unit = {}, onPitchChanged: (Float) -> Unit = {}, onHorizontalAngleChanged: (Float) -> Unit = {}, onVerticalAngleChanged: (Float) -> Unit = {}, onMinDistanceChanged: (Float) -> Unit = {}, onMaxDistanceChanged: (Float) -> Unit = {}, onFrustumVisibilityChanged: (Boolean) -> Unit = {}, onAnalysisVisibilityChanged: (Boolean) -> Unit = {}, onSetViewpointToAnalysisExtent: () -> Unit = {}, onResetViewshedOptions: () -> Unit = {}, mainPaneContent: @Composable BoxScope.() -> Unit,) { Scaffold( topBar = { SampleTopAppBar(title = stringResource(R.string.show_exploratory_viewshed_from_point_in_scene_app_name)) }, content = { paddingValues -> AdaptiveThreePane( modifier = Modifier .fillMaxSize() .padding(paddingValues), supportingPaneTitle = "Viewshed Options", floatingPaneTitle = "Scene Options", mainPane = { _, _ -> mainPaneContent() }, supportingPane = { isFloatingPaneVisible, toggleFloatingPane -> ViewshedSupportingContent( viewshedUiState = viewshedUiState, isFloatingPaneVisible = isFloatingPaneVisible, onHeadingChanged = onHeadingChanged, onPitchChanged = onPitchChanged, onHorizontalAngleChanged = onHorizontalAngleChanged, onVerticalAngleChanged = onVerticalAngleChanged, onMinDistanceChanged = onMinDistanceChanged, onMaxDistanceChanged = onMaxDistanceChanged, onToggleFloatingPane = toggleFloatingPane ) }, floatingPane = { ViewshedFloatingContent( viewshedUiState = viewshedUiState, onFrustumVisibilityChanged = onFrustumVisibilityChanged, onAnalysisVisibilityChanged = onAnalysisVisibilityChanged, onSetViewpointToAnalysisExtent = onSetViewpointToAnalysisExtent, onResetViewshedOptions = onResetViewshedOptions, ) } ) } )}
@SampleDeviceLightDarkPreview@Composablefun MainScreenPreview() { SamplePreviewSurface { MainScreenScaffold( viewshedUiState = ViewshedUiState( heading = 82f, pitch = 60f, horizontalAngle = 75f, verticalAngle = 90f, minDistance = 0f, maxDistance = 1500f, isFrustumVisible = true, isAnalysisVisible = true, ), mainPaneContent = {}, ) }}/* 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.showexploratoryviewshedfrompointinscene.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.material3.Buttonimport androidx.compose.material3.OutlinedButtonimport androidx.compose.material3.Sliderimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport com.esri.arcgismaps.sample.showexploratoryviewshedfrompointinscene.components.ViewshedUiState
@Composablefun ViewshedSupportingContent( viewshedUiState: ViewshedUiState, isFloatingPaneVisible: Boolean, onHeadingChanged: (Float) -> Unit = {}, onPitchChanged: (Float) -> Unit = {}, onHorizontalAngleChanged: (Float) -> Unit = {}, onVerticalAngleChanged: (Float) -> Unit = {}, onMinDistanceChanged: (Float) -> Unit = {}, onMaxDistanceChanged: (Float) -> Unit = {}, onToggleFloatingPane: () -> Unit = {},) { Column { HeadingSlider(viewshedUiState.heading, onHeadingChanged) PitchSlider(viewshedUiState.pitch, onPitchChanged) HorizontalAngleSlider(viewshedUiState.horizontalAngle, onHorizontalAngleChanged) VerticalAngleSlider(viewshedUiState.verticalAngle, onVerticalAngleChanged) MinimumDistanceSlider(viewshedUiState.minDistance, onMinDistanceChanged) MaximumDistanceSlider(viewshedUiState.maxDistance, onMaxDistanceChanged) Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalArrangement = Arrangement.Center ) { if (isFloatingPaneVisible) { Button(onClick = onToggleFloatingPane) { Text("Hide scene options") } } else { OutlinedButton(onClick = onToggleFloatingPane) { Text("Show scene options") } } } }}
/** * Custom slider implementation to be used by various viewshed slider options */@Composablefun ViewshedSlider( title: String, sliderValue: Float, sliderRangeValue: ClosedFloatingPointRange<Float>, onSliderValueChanged: (Float) -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text(text = title) Text(text = sliderValue.toInt().toString()) } Slider( modifier = Modifier.fillMaxWidth(), value = sliderValue, onValueChange = onSliderValueChanged, valueRange = sliderRangeValue ) }}
@Composableprivate fun HeadingSlider(heading: Float, onHeadingChanged: (Float) -> Unit) { ViewshedSlider( title = "Heading", sliderValue = heading, sliderRangeValue = 0f..360f, onSliderValueChanged = onHeadingChanged )}
@Composableprivate fun PitchSlider(pitch: Float, onPitchChanged: (Float) -> Unit) { ViewshedSlider( title = "Pitch", sliderValue = pitch, sliderRangeValue = 0f..180f, onSliderValueChanged = onPitchChanged )}
@Composableprivate fun HorizontalAngleSlider( horizontalAngle: Float, onHorizontalAngleChanged: (Float) -> Unit) { ViewshedSlider( title = "Horizontal Angle", sliderValue = horizontalAngle, sliderRangeValue = 1f..120f, onSliderValueChanged = onHorizontalAngleChanged )}
@Composableprivate fun VerticalAngleSlider(verticalAngle: Float, onVerticalAngleChanged: (Float) -> Unit) { ViewshedSlider( title = "Vertical Angle", sliderValue = verticalAngle, sliderRangeValue = 1f..120f, onSliderValueChanged = onVerticalAngleChanged )}
@Composableprivate fun MinimumDistanceSlider(minDistance: Float, onMinDistanceChanged: (Float) -> Unit) { ViewshedSlider( title = "Minimum Distance", sliderValue = minDistance, sliderRangeValue = 0f..8999f, onSliderValueChanged = onMinDistanceChanged )}
@Composableprivate fun MaximumDistanceSlider(maxDistance: Float, onMaxDistanceChanged: (Float) -> Unit) { ViewshedSlider( title = "Maximum Distance", sliderValue = maxDistance, sliderRangeValue = 0f..9999f, onSliderValueChanged = onMaxDistanceChanged )}/* 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.showexploratoryviewshedfrompointinscene.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.selection.toggleableimport androidx.compose.material3.Checkboximport androidx.compose.material3.OutlinedButtonimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.semantics.Roleimport androidx.compose.ui.unit.dpimport com.esri.arcgismaps.sample.showexploratoryviewshedfrompointinscene.components.ViewshedUiState
@Composablefun ViewshedFloatingContent( viewshedUiState: ViewshedUiState, onFrustumVisibilityChanged: (Boolean) -> Unit = {}, onAnalysisVisibilityChanged: (Boolean) -> Unit = {}, onSetViewpointToAnalysisExtent: () -> Unit = {}, onResetViewshedOptions: () -> Unit = {}) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { FrustumCheckbox(viewshedUiState.isFrustumVisible, onFrustumVisibilityChanged) AnalysisCheckbox(viewshedUiState.isAnalysisVisible, onAnalysisVisibilityChanged) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { OutlinedButton(onClick = onSetViewpointToAnalysisExtent) { Text("Align camera with viewshed") } OutlinedButton(onClick = onResetViewshedOptions) { Text("Reset viewshed options") } } }}
@Composableprivate fun FrustumCheckbox( isChecked: Boolean, onFrustumVisibilityChanged: (Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() .toggleable( value = isChecked, role = Role.Checkbox, onValueChange = onFrustumVisibilityChanged, ), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = isChecked, onCheckedChange = null, ) Text(text = "Frustum Outline") }}
@Composableprivate fun AnalysisCheckbox( isChecked: Boolean, onAnalysisVisibilityChanged: (Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() .toggleable( value = isChecked, role = Role.Checkbox, onValueChange = onAnalysisVisibilityChanged, ), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = isChecked, onCheckedChange = null, ) Text(text = "Analysis Overlay") }}