Perform a line of sight analysis in a map view between fixed observer and target positions.

Use case
Line of sight analysis determines whether a target can be seen from one or more observer locations based on elevation data. This can support planning workflows such as siting communication equipment, assessing observation coverage, or evaluating potential obstructions between known locations. In this sample, several predefined observer points are evaluated against a single fixed target to compare visibility outcomes side by side.
Note: This analysis is a form of “data-driven analysis”, which means the analysis is calculated at the resolution of the data rather than the resolution of the display.
How to use the sample
The sample loads with a map centered on the Isle of Arran, Scotland, and runs a line of sight analysis from multiple observer points (triangles) to a fixed target point (beacon icon) located at the highest point of the island. Solid green line segments represent visible portions of each line of sight result, and dashed gray segments represent not visible portions. Tap on each observer to see a callout that reports whether the target is visible and over what distance the line remains unobstructed. Use the checkbox to show only results where the target is visible from the observer.
How it works
- Create an
ArcGISMapand set it on aMapView. - Create three
GraphicsOverlays as follows:- One overlay to hold the target’s
Graphic. - One overlay to display a
Graphicfor each observer. Observers are in a separateGraphicsOverlayto allow us to detect when an observer graphic is tapped. - One overlay to display the line of sight result graphics.
- One overlay to hold the target’s
- Create a
ContinuousFieldfrom a raster file containing elevation data. - For each target (we have just one), create a list of
LineOfSightPositionfor the target and observers. - Configure
LineOfSightParameterswithObserverTargetPairscreated from the lists of observer and target line of sight positions. - Create a
LineOfSightFunctionfrom the continuous field and line of sight parameters. - Evaluate the function to get
LineOfSightresults. - Create
Graphics from each result, using the geometry of the result’svisibleLineand/ornotVisibleLineproperties, and appropriate symbols. - Use
LineOfSight.targetVisibilityto determine if the observer position has a direct line of sight to the target position. - Get the length of the visible line result with
GeometryEngine.lengthGeodeticto report result details.
Relevant API
- ContinuousField
- GeometryEngine
- GraphicsOverlay
- LineOfSight
- LineOfSightFunction
- LineOfSightParameters
- LineOfSightPosition
- ObserverTargetPairs
About the data
The sample uses a 10m resolution digital terrain elevation raster of the Isle of Arran, Scotland (Raster data Copyright Scottish Government and SEPA (2014)).
Tags
analysis, elevation, line of sight, map view, spatial analysis, terrain, visibility
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.showlineofsightanalysisinmap.components
import android.app.Applicationimport android.graphics.drawable.BitmapDrawableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.compose.ui.unit.dpimport androidx.core.content.ContextCompatimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.analysis.ContinuousFieldimport com.arcgismaps.analysis.HeightOriginimport com.arcgismaps.analysis.visibility.LineOfSightimport com.arcgismaps.analysis.visibility.LineOfSightFunctionimport com.arcgismaps.analysis.visibility.LineOfSightParametersimport com.arcgismaps.analysis.visibility.LineOfSightPositionimport com.arcgismaps.analysis.visibility.ObserverTargetPairsimport com.arcgismaps.geometry.GeodeticCurveTypeimport com.arcgismaps.geometry.GeometryEngineimport com.arcgismaps.geometry.LinearUnitimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.symbology.PictureMarkerSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyleimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.SingleTapConfirmedEventimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.Rimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.components.LineOfSightUiState.Companion.initialLineOfSightUiStateimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.flow.updateimport kotlinx.coroutines.launchimport java.io.File
class ShowLineOfSightAnalysisInMapViewModel(app: Application) : AndroidViewModel(app) { // Initialize and keep track of UI state private val _lineOfSightUiState = MutableStateFlow(initialLineOfSightUiState) val lineOfSightUiState = _lineOfSightUiState.asStateFlow()
// Create a MapViewProxy, used for identifyGraphicsOverlays val mapViewProxy = MapViewProxy()
// Initialize and keep track of the ArcGISMap & the overlays it uses private val targetPosition = Point(x = -577955.365, y = 7484288.220, z = 5.0, SpatialReference.webMercator()) val arcGISMap by mutableStateOf( ArcGISMap(BasemapStyle.ArcGISHillshadeDark).apply { initialViewpoint = Viewpoint(center = targetPosition, scale = 150000.0) } ) var targetGraphicsOverlay by mutableStateOf(GraphicsOverlay()) var observersGraphicsOverlay by mutableStateOf(GraphicsOverlay()) var resultsGraphicsOverlay by mutableStateOf(GraphicsOverlay())
// Keep track of which observer is selected & the content of the Callout (if any) var selectedObserverGraphic: Graphic? by mutableStateOf(null) var calloutContentTitle: String by mutableStateOf("") var calloutContentDetail: String? by mutableStateOf(null)
// Location of file containing elevation data private val provisionPath: String by lazy { app.getExternalFilesDir(null)?.path + File.separator + app.getString( R.string.show_line_of_sight_analysis_in_map_app_name ) + File.separator } private val filePath = provisionPath + app.getString(R.string.elevation_data_filename)
// Line of sight results by observer (for access when tapping on the observer graphics) private val lineOfSightResults = mutableMapOf<Observer, LineOfSight>()
// Create symbols for the visible and not visible line segments private val visibleLineSymbol = SimpleLineSymbol(color = Color.green, width = 4f) private val notVisibleLineSymbol = SimpleLineSymbol(style = SimpleLineSymbolStyle.LongDash, color = Color.gray, width = 2f)
// Create the observers private val observers = listOf( Observer( name = "Green Observer", color = Color.green, x = -580893.546, y = 7489102.890, ), Observer( name = "White Observer", color = Color.white, x = -583446.004, y = 7483567.462, ), Observer( name = "Cyan Observer", color = Color.cyan, x = -577665.236, y = 7490792.908, ), Observer( name = "Yellow Observer", color = Color.yellow, x = -576452.981, y = 7487071.388, ), Observer( name = "Magenta Observer", color = Color.magenta, x = -576650.067, y = 7481479.772, ), Observer( name = "Blue Observer", color = Color.blue, x = -571683.896, y = 7492017.864, ), )
// Used to surface errors to the Compose UI val messageDialogVM = MessageDialogViewModel()
init { viewModelScope.launch { // Create a graphic to mark the target position and add it to a graphics overlay val beaconDrawable = ContextCompat.getDrawable(app, R.drawable.beacon) as BitmapDrawable val beaconSymbol = PictureMarkerSymbol.createWithImage(beaconDrawable) beaconSymbol.apply { width = 24f height = 24f } val targetGraphic = Graphic(geometry = targetPosition, symbol = beaconSymbol) targetGraphicsOverlay.graphics.add(targetGraphic)
// Create a graphic for each observer and add them to a graphics overlay for ((index, observer) in observers.withIndex()) { val graphic = Graphic(geometry = observer.position, symbol = observer.symbol) graphic.attributes["observerIndex"] = index observersGraphicsOverlay.graphics.add(graphic) }
// Create a ContinuousField from a raster file containing elevation data val filePaths = listOf(filePath) ContinuousField.createFromFiles(filePaths, band = 0) .onFailure { messageDialogVM.showMessageDialog(it) }.onSuccess { continuousField -> // Create line of sight positions for target and observers val targetPositions = listOf( LineOfSightPosition(targetPosition, HeightOrigin.Relative) ) val observerPositions = observers.map { observer -> LineOfSightPosition(observer.position, HeightOrigin.Relative) }
// Create the line of sight parameters with the observer and target positions val parameters = LineOfSightParameters() parameters.observerTargetPairs = ObserverTargetPairs(observerPositions, targetPositions)
// Create a LineOfSightFunction from the continuous field and line of sight parameters val lineOfSightFunction = LineOfSightFunction(elevation = continuousField, parameters)
// Evaluate the function to get LineOfSight results lineOfSightFunction.evaluate() .onFailure { messageDialogVM.showMessageDialog(it) }.onSuccess { results -> // Store the results by observer for ((index, result) in results.withIndex()) { lineOfSightResults[observers[index]] = result }
// Add the line of sight results to a graphics overlay for (result in results) { // Use LineOfSight.targetVisibility to determine if the observer // position has a direct line of sight to the target position val targetVisibility = result.targetVisibility
// Add the visible line segment if it exists if (result.visibleLine != null) { val graphic = Graphic( geometry = result.visibleLine, symbol = visibleLineSymbol ) graphic.attributes["targetVisibility"] = targetVisibility resultsGraphicsOverlay.graphics.add(graphic) }
// Add the not visible line segment if it exists if (result.notVisibleLine != null) { val graphic = Graphic( geometry = result.notVisibleLine, symbol = notVisibleLineSymbol ) graphic.attributes["targetVisibility"] = targetVisibility resultsGraphicsOverlay.graphics.add(graphic) } } } } } }
/** * Set the visibility filter to [value]. A value of `false` causes all results to be shown, * whereas `true` causes results for which the target is not visible to be hidden. */ fun setVisibilityFilter(value: Boolean) { // Update UI state _lineOfSightUiState.update { it.copy(visibilityFilter = value) }
// If the visibility filter is selected (true), hide results graphics for which the target // is not visible for (graphic in resultsGraphicsOverlay.graphics) { val targetVisibility = graphic.attributes["targetVisibility"] as Float graphic.isVisible = !value || targetVisibility == 1.0f } }
/** * Handle a tap at the given [singleTapConfirmedEvent]. */ fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) { viewModelScope.launch { // Dismiss any existing callout selectedObserverGraphic = null
// Identify graphic(s) at the tap position mapViewProxy.identifyGraphicsOverlays( singleTapConfirmedEvent.screenCoordinate, tolerance = 10.dp ).onSuccess { resultsList -> if (resultsList.isNotEmpty()) { // Find the first (if any) result from the graphics overlay containing observers val identifyResult = resultsList.find { result -> result.graphicsOverlay == observersGraphicsOverlay } if (identifyResult != null) { val graphics = identifyResult.graphics if (graphics.isNotEmpty()) { val observerGraphic = graphics.first()
// Get the observer, using the index retrieved from the graphic attributes val observer = observers[observerGraphic.attributes["observerIndex"] as Int]
// Get the line of sight result for the observer val lineOfSightResult = lineOfSightResults[observer]
// Display a callout with the result details selectedObserverGraphic = observerGraphic calloutContentTitle = observer.name calloutContentDetail = lineOfSightResult?.detail() } } } } } }}
/** * Returns a String describing the contents of this LineOfSight. */fun LineOfSight.detail(): String? { // If there was an error during the analysis, return the error message error?.let { return it.message }
// If neither line is present, return an empty string (though this should not happen in a valid // result) if (notVisibleLine == null && visibleLine == null) return ""
// Calculate the length of the visible line, which is the unobstructed distance from the // observer to the target val visibleLength = visibleLine?.let { GeometryEngine.lengthGeodetic( geometry = it, lengthUnit = LinearUnit.meters, curveType = GeodeticCurveType.Geodesic ) } ?: 0.0 val formattedVisibleLength = "%.3f".format(visibleLength)
// If there is no not-visible line, the target is fully visible from the observer; return a // message with the length of the visible line if (notVisibleLine == null) { return "Target visible from observer over $formattedVisibleLength meters." }
// Otherwise, the target is not fully visible; return a message with the unobstructed length return "Target not visible from observer. Obstructed after $formattedVisibleLength meters."}
data class LineOfSightUiState( val visibilityFilter: Boolean) { companion object { // Initial values to drive the UI on launch val initialLineOfSightUiState = LineOfSightUiState( visibilityFilter = false ) }}
data class Observer( val name: String, val color: Color, val x: Double, val y: Double) { val position = Point(x, y, SpatialReference.webMercator()) val symbol = SimpleMarkerSymbol(style = SimpleMarkerSymbolStyle.Triangle, color, size = 15f)}/* 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.showlineofsightanalysisinmap
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( Intent(this, MainActivity::class.java), // get the app name of the sample getString(R.string.show_line_of_sight_analysis_in_map_app_name), 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.showlineofsightanalysisinmap
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.enableEdgeToEdgeimport androidx.activity.compose.setContentimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.screens.ShowLineOfSightAnalysisInMapScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
enableEdgeToEdge() setContent { SampleAppTheme { Surface(color = MaterialTheme.colorScheme.background) { ShowLineOfSightAnalysisInMapScreen() } } } }}/* 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.showlineofsightanalysisinmap.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.selection.toggleableimport androidx.compose.material3.Checkboximport 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.showlineofsightanalysisinmap.components.LineOfSightUiState
@Composablefun LineOfSightSupportingContent( uiState: LineOfSightUiState, onVisibilityFilterChanged: (Boolean) -> Unit = {}) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Tap an observer to see information about it.") VisibilityFilterCheckbox(uiState.visibilityFilter, onVisibilityFilterChanged) }}
@Composableprivate fun VisibilityFilterCheckbox( isChecked: Boolean, onVisibilityFilterChanged: (Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() .toggleable( value = isChecked, role = Role.Checkbox, onValueChange = onVisibilityFilterChanged, ), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( modifier = Modifier.padding(all = 6.dp), checked = isChecked, onCheckedChange = null, ) Text(text = "Show results only where target is visible") }}/* 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.showlineofsightanalysisinmap.screens
import androidx.compose.foundation.layout.BoxScopeimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.PaddingValuesimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.sizeInimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.compose.ui.text.style.TextAlignimport androidx.compose.ui.unit.dpimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.arcgismaps.toolkit.geoviewcompose.theme.CalloutDefaultsimport 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.ThreePaneConfigimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.Rimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.components.LineOfSightUiStateimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.components.LineOfSightUiState.Companion.initialLineOfSightUiStateimport com.esri.arcgismaps.sample.showlineofsightanalysisinmap.components.ShowLineOfSightAnalysisInMapViewModel
/** * Main screen layout for the sample app. */@Composablefun ShowLineOfSightAnalysisInMapScreen() { val viewModel: ShowLineOfSightAnalysisInMapViewModel = viewModel() val uiState by viewModel.lineOfSightUiState.collectAsStateWithLifecycle()
MainScreenScaffold( uiState = uiState, onVisibilityFilterChanged = viewModel::setVisibilityFilter, mainPaneContent = { Column(modifier = Modifier.fillMaxSize()) { RasterDataCopyrightText() MapView( arcGISMap = viewModel.arcGISMap, mapViewProxy = viewModel.mapViewProxy, graphicsOverlays = listOf( viewModel.resultsGraphicsOverlay, viewModel.observersGraphicsOverlay, viewModel.targetGraphicsOverlay ), onSingleTapConfirmed = viewModel::onTap, content = { // Show a callout only when an observer has been selected viewModel.selectedObserverGraphic?.let { graphic -> Callout( geoElement = graphic, modifier = Modifier.sizeIn(maxWidth = 250.dp), shapes = CalloutDefaults.shapes( calloutContentPadding = PaddingValues(all = 4.dp) ), colorScheme = CalloutDefaults.colors( backgroundColor = MaterialTheme.colorScheme.background, borderColor = MaterialTheme.colorScheme.outline ) ) { // Callout content: Column { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = viewModel.calloutContentTitle, style = MaterialTheme.typography.labelSmall ) viewModel.calloutContentDetail?.let { string -> Text( text = string, style = MaterialTheme.typography.bodySmall ) } } } } } ) // Show a message dialog if the viewmodel reported an error viewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } } )}
@Composableprivate fun MainScreenScaffold( uiState: LineOfSightUiState, onVisibilityFilterChanged: (Boolean) -> Unit = {}, mainPaneContent: @Composable BoxScope.() -> Unit,) { Scaffold( topBar = { SampleTopAppBar(title = stringResource(R.string.show_line_of_sight_analysis_in_map_app_name)) }, content = { paddingValues -> AdaptiveThreePane( modifier = Modifier.fillMaxSize().padding(paddingValues), supportingPaneTitle = "Line of Sight Options", config = ThreePaneConfig(compactSupportingPaneHeightRatio = 0.25f), mainPane = { _, _ -> mainPaneContent() }, supportingPane = { _, _ -> LineOfSightSupportingContent(uiState, onVisibilityFilterChanged) } ) } )}
/** * Display copyright text for the raster data we are using. */@Composablefun RasterDataCopyrightText() { Text( text = "Raster data copyright Scottish Government and SEPA (2014)", style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() )}
@SampleDeviceLightDarkPreview@Composablefun MainScreenPreview() { SamplePreviewSurface { MainScreenScaffold( uiState = initialLineOfSightUiState, mainPaneContent = {} ) }}