Use a geoprocessing service and a set of features to identify statistically significant hot spots and cold spots.

Use case
This tool identifies statistically significant spatial clusters of high values (hot spots) and low values (cold spots). For example, a hotspot analysis based on the frequency of 911 calls within a set region.
How to use the sample
Tap on Analyze, and select a date from the “FROM” DatePicker and “TO” DatePicker to get a date range from the BottomSheet and tap on Run analysis. The results will be shown on the map upon successful completion of the GeoprocessingJob.
How it works
- Create a
GeoprocessingTaskwith the URL set to the endpoint of a geoprocessing service. - Create a query string with the date range as an input of
GeoprocessingParameters. - Use the
GeoprocessingTaskto create aGeoprocessingJobwith theGeoprocessingParametersinstance. - Start the
GeoprocessingJoband wait for it to complete and return aGeoprocessingResult. - Get the resulting
ArcGISMapImageLayerusingGeoprocessingResult.mapImageLayer. - Add the layer to the map’s operational layers.
Relevant API
- GeoprocessingJob
- GeoprocessingParameters
- GeoprocessingResult
- GeoprocessingTask
Additional information
This sample uses the GeoView-Compose Toolkit module to be able to implement a composable MapView.
Tags
analysis, density, geoprocessing, geoview-compose, hot spots, hotspots, toolkit
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.analyzehotspots
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.analyzehotspots.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 { AnalyzeHotspotsApp() } } }
@Composable private fun AnalyzeHotspotsApp() { Surface( color = MaterialTheme.colorScheme.background ) { MainScreen( sampleName = getString(R.string.analyze_hotspots_app_name) ) } }}/* 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.analyzehotspots.components
import android.app.Applicationimport android.util.Logimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableIntStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.lifecycle.AndroidViewModelimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.tasks.geoprocessing.GeoprocessingJobimport com.arcgismaps.tasks.geoprocessing.GeoprocessingParametersimport com.arcgismaps.tasks.geoprocessing.GeoprocessingResultimport com.arcgismaps.tasks.geoprocessing.GeoprocessingTaskimport com.arcgismaps.tasks.geoprocessing.geoprocessingparameters.GeoprocessingStringimport com.esri.arcgismaps.sample.analyzehotspots.Rimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.launchimport java.time.Instantimport java.time.LocalDateTimeimport java.time.ZoneIdimport java.time.format.DateTimeFormatter
class MapViewModel( private val application: Application, private val sampleCoroutineScope: CoroutineScope,) : AndroidViewModel(application) { // create a map using the topographic basemap style val map: ArcGISMap by mutableStateOf(ArcGISMap(BasemapStyle.ArcGISTopographic))
// create a ViewModel to handle dialog interactions val messageDialogVM: MessageDialogViewModel = MessageDialogViewModel()
// determinate job progress loading dialog val showJobProgressDialog = mutableStateOf(false)
// determinate job progress percentage val geoprocessingJobProgress = mutableIntStateOf(0)
// job used to run the geoprocessing task on a service private var geoprocessingJob: GeoprocessingJob? = null
init { map.apply { // Set the map's initialViewpoint initialViewpoint = Viewpoint( center = Point(-13671170.0, 5693633.0, SpatialReference(wkid = 3857)), scale = 1e5 ) } } /** * Creates a [geoprocessingJob] with the default [GeoprocessingParameters] * and a custom query date range between [fromDate] & [toDate] */ suspend fun createGeoprocessingJob( fromDate: String, toDate: String, ) { // a map image layer might be generated, clear previous results map.operationalLayers.clear()
// create and load geoprocessing task val geoprocessingTask = GeoprocessingTask(application.getString(R.string.service_url)) geoprocessingTask.load().getOrElse { messageDialogVM.showMessageDialog(it.message.toString(), it.cause.toString()) }
// create parameters for geoprocessing job val geoprocessingParameters = geoprocessingTask.createDefaultParameters().getOrElse { messageDialogVM.showMessageDialog(it.message.toString(), it.cause.toString()) } as GeoprocessingParameters
val queryString = StringBuilder("(\"DATE\" > date '") .append(fromDate) .append(" 00:00:00' AND \"DATE\" < date '") .append(toDate) .append(" 00:00:00')")
geoprocessingParameters.inputs["Query"] = GeoprocessingString(queryString.toString())
// create and start geoprocessing job geoprocessingJob = geoprocessingTask.createJob(geoprocessingParameters)
runGeoprocessingJob() }
/** * Starts the [geoprocessingJob], shows the progress dialog and * displays the result hotspot map image layer to the MapView */ private suspend fun runGeoprocessingJob() { geoprocessingJob?.let { geoprocessingJob -> // display the progress dialog showJobProgressDialog.value = true // start the job geoprocessingJob.start() // collect the job progress sampleCoroutineScope.launch { geoprocessingJob.progress.collect { progress -> // updates the job progress dialog geoprocessingJobProgress.intValue = progress Log.i("Progress", "geoprocessingJobProgress: ${geoprocessingJobProgress.intValue}") } } // get the result of the job on completion geoprocessingJob.result().onSuccess { // dismiss the progress dialog showJobProgressDialog.value = false // get the job's result val geoprocessingResult = geoprocessingJob.result().getOrElse { messageDialogVM.showMessageDialog(it.message.toString(), it.cause.toString()) } as GeoprocessingResult // resulted hotspot map image layer val hotspotMapImageLayer = geoprocessingResult.mapImageLayer?.apply { opacity = 0.5f } ?: return messageDialogVM.showMessageDialog("Result map image layer is null")
// add new layer to map map.operationalLayers.add(hotspotMapImageLayer) }.onFailure { throwable -> messageDialogVM.showMessageDialog( title = throwable.message.toString(), description = throwable.cause.toString() ) showJobProgressDialog.value = false } } }
fun cancelGeoprocessingJob() { sampleCoroutineScope.launch { geoprocessingJob?.cancel() } }
/** * Convert epoch time in [millis] to String date format */ fun convertMillisToString(millis: Long): String { val instant = Instant.ofEpochMilli(millis) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val date = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) return date.format(formatter) }}/* 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.analyzehotspots.screens
import android.content.res.Configurationimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.Buttonimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.ModalBottomSheetimport androidx.compose.material3.Textimport androidx.compose.material3.rememberModalBottomSheetStateimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.tooling.preview.Previewimport androidx.compose.ui.unit.dpimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme
/** * Bottom app content with a button to toggle the bottom sheet layout. * Accepts a lambda for the [analyzeHotspotsRange]. */@OptIn(ExperimentalMaterial3Api::class)@Composablefun BottomAppContent( analyzeHotspotsRange: (Long?, Long?) -> Unit,) { // boolean to toggle the state of the bottom sheet layout var showAnalysisOptions by remember { mutableStateOf(false) }
Column( modifier = Modifier.padding(12.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { // button to display analyze hotspot options Button(onClick = { showAnalysisOptions = true }) { Text(text = "Analyze") // expands the bottom sheet if true if (showAnalysisOptions) { // modal to control the bottom sheet state val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( sheetState = bottomSheetState, onDismissRequest = { showAnalysisOptions = false }, ) { // displays a date range picker using a bottom sheet DateRangeSelectorLayout( bottomSheetState = bottomSheetState, onBottomSheetDismiss = { // hide the bottom sheet showAnalysisOptions = false }, onRunAnalysisClicked = { from, to -> analyzeHotspotsRange(from, to) } ) } } } }}
@Preview(showBackground = true)@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)@Composablefun PreviewBottomAppContent() { SampleAppTheme { BottomAppContent(analyzeHotspotsRange = { _, _ -> }) }}/* 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.analyzehotspots.screens
import android.content.res.Configurationimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.wrapContentWidthimport androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.material3.Buttonimport androidx.compose.material3.DatePickerimport androidx.compose.material3.DisplayModeimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.SheetStateimport androidx.compose.material3.Textimport androidx.compose.material3.rememberDatePickerStateimport androidx.compose.material3.rememberModalBottomSheetStateimport androidx.compose.runtime.Composableimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.ui.Alignment.Companion.CenterHorizontallyimport androidx.compose.ui.Modifierimport androidx.compose.ui.tooling.preview.Previewimport androidx.compose.ui.unit.dpimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport kotlinx.coroutines.launch
/** * Bottom sheet layout to select a date range */@OptIn(ExperimentalMaterial3Api::class)@Composablefun DateRangeSelectorLayout( onBottomSheetDismiss: () -> Unit, onRunAnalysisClicked: (Long?, Long?) -> Unit, bottomSheetState: SheetState,) { // coroutineScope that will be cancelled when this call leaves the composition val scope = rememberCoroutineScope()
// From: Dec 31st, 1997 val fromDatePickerState = rememberDatePickerState( initialSelectedDateMillis = 883526400000, initialDisplayMode = DisplayMode.Picker ) // From: Jan 14th, 1998 val toDatePickerState = rememberDatePickerState( initialSelectedDateMillis = 884736000000, initialDisplayMode = DisplayMode.Picker ) Column { Button( modifier = Modifier.wrapContentWidth().padding(12.dp).align(CenterHorizontally), onClick = { // get the selected date range in millis onRunAnalysisClicked( fromDatePickerState.selectedDateMillis, toDatePickerState.selectedDateMillis ) // hide the bottom sheet scope.launch { bottomSheetState.hide() }.invokeOnCompletion { if (!bottomSheetState.isVisible) { onBottomSheetDismiss() } } } ) { Text("Run analysis") } LazyColumn { // from date picker item { DatePicker( state = fromDatePickerState, modifier = Modifier.padding(16.dp), title = { Text(text = "FROM") } ) } // to date picker item { DatePicker( state = toDatePickerState, modifier = Modifier.padding(16.dp), title = { Text(text = "TO") } ) } } }}
@OptIn(ExperimentalMaterial3Api::class)@Preview(showBackground = true)@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)@Composablefun PreviewBottomSheetScreen() { SampleAppTheme { DateRangeSelectorLayout( onBottomSheetDismiss = {}, onRunAnalysisClicked = { _, _ -> }, bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) }}/* 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.analyzehotspots.screens
import android.app.Applicationimport 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.rememberimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.ui.Modifierimport androidx.compose.ui.platform.LocalContextimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.analyzehotspots.components.MapViewModelimport com.esri.arcgismaps.sample.sampleslib.components.JobLoadingDialogimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport kotlinx.coroutines.launch
/** * Main screen layout for the sample app */@Composablefun MainScreen(sampleName: String) { // coroutineScope that will be cancelled when this call leaves the composition val sampleCoroutineScope = rememberCoroutineScope() // get the application property that will be used to construct MapViewModel val sampleApplication = LocalContext.current.applicationContext as Application // create a ViewModel to handle MapView interactions val mapViewModel = remember { MapViewModel(sampleApplication, sampleCoroutineScope) }
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { // sample app content layout Column(modifier = Modifier .fillMaxSize() .padding(it)) { MapView( modifier = Modifier .fillMaxSize() .weight(1f), arcGISMap = mapViewModel.map ) // bottom layout with a button to display analyze hotspot options BottomAppContent( // date range selected to analyze analyzeHotspotsRange = { fromDateInMillis, toDateInMillis -> if (fromDateInMillis != null && toDateInMillis != null) { if (fromDateInMillis > toDateInMillis) { mapViewModel.messageDialogVM.showMessageDialog( title = "Invalid date range", description = "The selected \"TO\" date cannot be before the \"FROM\" date" ) } else { sampleCoroutineScope.launch { // create and run a geoprocessing task using date range mapViewModel.createGeoprocessingJob( fromDate = mapViewModel.convertMillisToString(fromDateInMillis), toDate = mapViewModel.convertMillisToString(toDateInMillis), ) } } } else { mapViewModel.messageDialogVM.showMessageDialog( title = "Error creating job", description = "Invalid date range selected" ) } }, ) // display progress dialog while analyzing hotspots if (mapViewModel.showJobProgressDialog.value) { JobLoadingDialog( title = "Analyzing hotspots...", progress = mapViewModel.geoprocessingJobProgress.intValue, cancelJobRequest = { mapViewModel.cancelGeoprocessingJob() } ) }
// display a dialog if the sample encounters an error mapViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } })}