diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd5a642..562ce30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { minSdk = 29 targetSdk = 36 - versionCode = 1708536379 - versionName = "0.31.2-beta" + versionCode = 1708536380 + versionName = "0.32.0-beta" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/f/cking/software/domain/model/LocationModel.kt b/app/src/main/java/f/cking/software/domain/model/LocationModel.kt index 51e5b9b..0402d98 100644 --- a/app/src/main/java/f/cking/software/domain/model/LocationModel.kt +++ b/app/src/main/java/f/cking/software/domain/model/LocationModel.kt @@ -1,6 +1,7 @@ package f.cking.software.domain.model import android.location.Location +import org.osmdroid.util.GeoPoint import java.io.Serializable @kotlinx.serialization.Serializable @@ -15,4 +16,20 @@ data class LocationModel( Location.distanceBetween(lat, lng, other.lat, other.lng, result) return result[0] } +} + +fun LocationModel.toLocation(): Location { + val location = Location("") + location.latitude = lat + location.longitude = lng + location.time = time + return location +} + +fun LocationModel.toGeoPoint(): GeoPoint { + return GeoPoint(lat, lng) +} + +fun Location.toGeoPoint(): GeoPoint { + return GeoPoint(latitude, longitude) } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/ui/AsyncBatchProcessor.kt b/app/src/main/java/f/cking/software/ui/AsyncBatchProcessor.kt index fb1e101..1c0bb0a 100644 --- a/app/src/main/java/f/cking/software/ui/AsyncBatchProcessor.kt +++ b/app/src/main/java/f/cking/software/ui/AsyncBatchProcessor.kt @@ -3,10 +3,9 @@ package f.cking.software.ui import android.os.Handler import android.os.Looper import android.os.SystemClock -import java.util.* /** - * The helper class splits large rendering operations into small subtasks to don't stuck the main thread for a long time. + * The helper class splits large rendering operations into small subtasks in order not to freeze the main thread. * * @param frameRate frames per second * @param renderLoad percentage of one frame rendering time that may be busy bu batch processor's task diff --git a/app/src/main/java/f/cking/software/ui/MainActivity.kt b/app/src/main/java/f/cking/software/ui/MainActivity.kt index 08e7fbe..312b561 100644 --- a/app/src/main/java/f/cking/software/ui/MainActivity.kt +++ b/app/src/main/java/f/cking/software/ui/MainActivity.kt @@ -13,6 +13,8 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography @@ -21,11 +23,18 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -35,6 +44,7 @@ import f.cking.software.data.helpers.ActivityProvider import f.cking.software.data.helpers.IntentHelper import f.cking.software.data.helpers.PermissionHelper import f.cking.software.isDarkModeOn +import f.cking.software.utils.ScreenSizeLocal import f.cking.software.utils.graphic.rememberProgressDialog import f.cking.software.utils.navigation.BackCommand import f.cking.software.utils.navigation.Navigator @@ -86,13 +96,24 @@ class MainActivity : AppCompatActivity() { bodySmall = MaterialTheme.typography.bodySmall.copy(color = colors.onSurface), ) ) { - val stack = viewModel.navigator.stack - if (stack.isEmpty()) { - finish() - } else { - focusManager.clearFocus(true) - stack.forEach { screen -> - screen() + var screenSize by remember { mutableStateOf(IntSize(0, 0)) } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { layoutCoordinates -> + screenSize = layoutCoordinates.size + } + ) { + val stack = viewModel.navigator.stack + if (stack.isEmpty()) { + finish() + } else { + focusManager.clearFocus(true) + CompositionLocalProvider(ScreenSizeLocal provides screenSize) { + stack.forEach { screen -> + screen() + } + } } } @@ -183,6 +204,7 @@ class MainActivity : AppCompatActivity() { outlineVariant = colorResource(id = R.color.md_theme_dark_outlineVariant), scrim = colorResource(id = R.color.md_theme_dark_scrim), ) + !darkMode -> lightColorScheme( primary = colorResource(id = R.color.md_theme_light_primary), onPrimary = colorResource(id = R.color.md_theme_light_onPrimary), @@ -217,6 +239,7 @@ class MainActivity : AppCompatActivity() { outlineVariant = colorResource(id = R.color.md_theme_light_outlineVariant), scrim = colorResource(id = R.color.md_theme_light_scrim), ) + else -> throw IllegalStateException("This state is unreachable") } return colors diff --git a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt index 6dfca64..8c2f1f5 100644 --- a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt +++ b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt @@ -4,6 +4,7 @@ import android.bluetooth.BluetoothGattCharacteristic import android.graphics.Paint import android.view.MotionEvent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -28,6 +29,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.FullscreenExit import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -74,28 +77,45 @@ import androidx.compose.ui.unit.sp import com.google.accompanist.flowlayout.FlowRow import com.vanpra.composematerialdialogs.rememberMaterialDialogState import f.cking.software.R +import f.cking.software.bottomRight import f.cking.software.dateTimeStringFormat import f.cking.software.domain.model.DeviceData import f.cking.software.domain.model.LocationModel import f.cking.software.domain.model.isNullOrEmpty +import f.cking.software.domain.model.toGeoPoint +import f.cking.software.domain.model.toLocation import f.cking.software.dpToPx import f.cking.software.frameRate +import f.cking.software.mapParallel +import f.cking.software.pxToDp +import f.cking.software.splitToBatchesEqual +import f.cking.software.toLocation +import f.cking.software.topLeft import f.cking.software.ui.AsyncBatchProcessor import f.cking.software.ui.map.MapView import f.cking.software.ui.tagdialog.TagDialog +import f.cking.software.utils.ScreenSizeLocal import f.cking.software.utils.graphic.DevicePairedIcon import f.cking.software.utils.graphic.DeviceTypeIcon import f.cking.software.utils.graphic.ExtendedAddressView import f.cking.software.utils.graphic.GlassSystemNavbar +import f.cking.software.utils.graphic.HeatMapBitmapFactory +import f.cking.software.utils.graphic.HeatMapBitmapFactory.Tile import f.cking.software.utils.graphic.ListItem import f.cking.software.utils.graphic.RadarIcon import f.cking.software.utils.graphic.RoundedBox import f.cking.software.utils.graphic.SignalData +import f.cking.software.utils.graphic.Switcher import f.cking.software.utils.graphic.SystemNavbarSpacer import f.cking.software.utils.graphic.TagChip import f.cking.software.utils.graphic.ThemedDialog import f.cking.software.utils.graphic.infoDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.osmdroid.events.MapListener @@ -104,7 +124,9 @@ import org.osmdroid.events.ZoomEvent import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.GroundOverlay import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlay import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlayOptions @@ -115,6 +137,8 @@ import java.time.format.FormatStyle @OptIn(ExperimentalMaterial3Api::class) object DeviceDetailsScreen { + private const val TAG = "DeviceDetailsScreen" + @Composable fun Screen( address: String, @@ -187,7 +211,7 @@ object DeviceDetailsScreen { if (deviceData == null) { Progress(modifier) } else { - DeviceDetails(modifier = modifier, viewModel = viewModel, deviceData = deviceData) + DeviceDetails(modifier, viewModel, deviceData) } } @@ -205,11 +229,15 @@ object DeviceDetailsScreen { private fun DeviceDetails( modifier: Modifier, viewModel: DeviceDetailsViewModel, - deviceData: DeviceData + deviceData: DeviceData, ) { var scrollEnabled by remember { mutableStateOf(true) } val isMoving = remember { mutableStateOf(false) } + val screenHeight = ScreenSizeLocal.current.height + val expandedHeight = screenHeight * 0.9f + val collapsedHeight = screenHeight * 0.4f + LaunchedEffect(isMoving.value) { scrollEnabled = !isMoving.value } @@ -220,10 +248,14 @@ object DeviceDetailsScreen { .verticalScroll(rememberScrollState(), scrollEnabled) .fillMaxSize(), ) { + + val mapToolkitOffsetDp = 100 + val mapBlockSizePx = LocalContext.current.pxToDp(if (viewModel.mapExpanded) expandedHeight else collapsedHeight) + mapToolkitOffsetDp LocationHistory( modifier = Modifier .fillMaxWidth() - .height(400.dp), + .animateContentSize() + .height(mapBlockSizePx.dp), deviceData = deviceData, viewModel = viewModel, isMoving = isMoving, @@ -644,6 +676,19 @@ object DeviceDetailsScreen { ) } + @Composable + private fun HeatMapSettings(viewModel: DeviceDetailsViewModel) { + Switcher( + modifier = Modifier.fillMaxWidth(), + value = viewModel.useHeatmap, + title = stringResource(R.string.device_history_pint_style_heatmap), + subtitle = null, + onClick = { + viewModel.useHeatmap = !viewModel.useHeatmap + } + ) + } + @Composable private fun HistoryPeriod( deviceData: DeviceData, @@ -703,6 +748,7 @@ object DeviceDetailsScreen { Box( modifier = Modifier .fillMaxWidth() + .animateContentSize() .weight(1f) ) { Map( @@ -719,6 +765,7 @@ object DeviceDetailsScreen { } } if (mapIsReady) { + HeatMapSettings(viewModel) PointsStyle(viewModel) HistoryPeriod(deviceData = deviceData, viewModel = viewModel) } @@ -751,7 +798,7 @@ object DeviceDetailsScreen { } } - if (viewModel.markersInLoadingState) { + if (viewModel.markersInLoadingState || viewModel.loadingHeatmap) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -774,7 +821,24 @@ object DeviceDetailsScreen { Icon( imageVector = Icons.Outlined.Info, contentDescription = stringResource(R.string.device_map_disclaimer_title), - modifier = Modifier.size(24.dp) + modifier = Modifier + .size(24.dp) + .background(Color.Black.copy(alpha = 0.1f), shape = CircleShape), + tint = Color.DarkGray, + ) + } + + IconButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { + viewModel.mapExpanded = !viewModel.mapExpanded + }, + ) { + Icon( + imageVector = if (viewModel.mapExpanded) Icons.Outlined.FullscreenExit else Icons.Outlined.Fullscreen, + contentDescription = stringResource(R.string.device_map_expand_title), + modifier = Modifier + .size(24.dp) .background(Color.Black.copy(alpha = 0.1f), shape = CircleShape), tint = Color.DarkGray, ) @@ -813,7 +877,7 @@ object DeviceDetailsScreen { }, onStart = { map -> isLoading.invoke(true) - map.overlays.clear() + map.overlays.clearPoints() map.invalidate() }, onComplete = { map -> @@ -830,6 +894,23 @@ object DeviceDetailsScreen { var mapView: MapView? by remember { mutableStateOf(null) } val colorScheme = MaterialTheme.colorScheme + + fun getViewport(): Tile? { + val mapView = mapView ?: return null + return Tile(topLeft = mapView.projection.topLeft().toLocation(), mapView.projection.bottomRight().toLocation()) + } + + var pendingViewport: Tile? by remember { mutableStateOf(getViewport()) } + var committedViewport: Tile? by remember { mutableStateOf(pendingViewport) } + + fun updateViewport() { + pendingViewport = getViewport() + } + + fun commitViewPort() { + committedViewport = pendingViewport + } + MapView( modifier = modifier.pointerInteropFilter { event -> if (mapView != null) { @@ -859,26 +940,48 @@ object DeviceDetailsScreen { initMapState(map, colorScheme) mapIsReadyToUse.invoke() map.addMapListener(object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean { isMoving.value = false + updateViewport() return true } override fun onZoom(event: ZoomEvent?): Boolean { - // do nothing + updateViewport() return true } }) + updateViewport() }, onUpdate = { map -> mapView = map } ) val mapColorScheme = remember { MapColorScheme(colorScheme.scrim.copy(alpha = 0.6f), Color.Red) } - LaunchedEffect(mapView, viewModel.pointsState, viewModel.pointsStyle) { - if (mapView != null) { - val mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView!!) + if (mapView != null) { + val mapView = mapView!! + + val mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView) + + LaunchedEffect(pendingViewport) { + delay(50L) + yield() + commitViewPort() + } + + LaunchedEffect(mapView, viewModel.pointsState, viewModel.pointsStyle) { refreshMap(mapUpdate, batchProcessor, mapColorScheme, viewModel.pointsStyle) } + + LaunchedEffect(mapView, viewModel.pointsState) { + updateMapCamera(mapUpdate) + } + + val tilesState = rememberTilesState() + LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap, committedViewport) { + val committedViewport = committedViewport ?: return@LaunchedEffect + renderHeatmap(mapUpdate, committedViewport, viewModel, tilesState) + } } } @@ -901,13 +1004,160 @@ object DeviceDetailsScreen { val pointColor: Color, ) + + private const val PADDING_METERS = 50.0 + private const val TILE_SIZE_METERS = 300.0 + + @Composable + private fun rememberTilesState() = remember { TilesState() } + private class TilesState { + var tiles = HashMap() + var lastLocationsState: List = emptyList() + } + + private data class TilesData( + val tile: Tile, + val overlay: GroundOverlay, + val locations: List, + ) + + private suspend fun renderHeatmap(mapUpdate: MapUpdate, viewport: Tile, viewModel: DeviceDetailsViewModel, tilesState: TilesState) { + + if (viewModel.useHeatmap) { + withContext(Dispatchers.Default) { + val locations = mapUpdate.points.map { it.toLocation() } + Timber.tag(TAG).d("Heatmap points: ${locations.size}") + val pointsChanged = tilesState.lastLocationsState.size != locations.size + val tiles = HeatMapBitmapFactory.buildTilesWithRenderPaddingStable(locations, TILE_SIZE_METERS, PADDING_METERS) + .asSequence() + .filter { + val inViewport = viewport.intersects(it) + + if (!pointsChanged) { + val existedTile = tilesState.tiles[it] + val isAdded = mapUpdate.map.overlays.contains(existedTile?.overlay) + inViewport && (!tilesState.tiles.containsKey(it) || !isAdded) + } else { + inViewport + } + } + .sortedBy { tilesState.tiles.containsKey(it) } + .toList() + + Timber.tag(TAG).d("Heatmap tiles: ${tiles.size}") + + if (pointsChanged) { + val removedTiles = tilesState.tiles.values.filter { !tiles.contains(it.tile) } + removedTiles.forEach { tileData -> + Timber.tag(TAG).d("Tile exists but should be removed") + tilesState.tiles.remove(tileData.tile) + mapUpdate.map.overlays.remove(tileData.overlay) + } + } + + tilesState.lastLocationsState = mapUpdate.points + + val loadingJob = launch(Dispatchers.Main) { + delay(30) + yield() + viewModel.loadingHeatmap = true + } + + tiles.splitToBatchesEqual(10).mapParallel { batch -> + batch.map { tile -> + withContext(Dispatchers.Default) { + + val positionsForTile = locations + .mapNotNull { + it.takeIf { tile.contains(it, PADDING_METERS) } + ?.let { HeatMapBitmapFactory.Position(it, PADDING_METERS.toFloat()) } + } + val existedTile = tilesState.tiles[tile] + + if (existedTile != null && positionsForTile == existedTile.locations) { + // tile is already added and didn't change + Timber.tag(TAG).d("Tile already rendered") + withContext(Dispatchers.IO) { + if (!mapUpdate.map.overlays.contains(existedTile.overlay)) { + mapUpdate.map.overlays.add(0, existedTile.overlay) + mapUpdate.map.invalidate() + } + } + return@withContext + } else if (existedTile != null) { + // tile is rendered but changed (need to re-render) + Timber.tag(TAG).d("Tile exists but changed") + mapUpdate.map.overlays.remove(existedTile.overlay) + tilesState.tiles.remove(tile) + } + Timber.tag(TAG).d("Rendering tile with ${positionsForTile.size} points") + val bitmap = HeatMapBitmapFactory.generateTileGradientBitmapFastSeamless( + positionsAll = positionsForTile, + coreTile = tile, + widthPxCore = 300, + renderPaddingMeters = PADDING_METERS, + debugBorderPx = 0, + ) + + yield() + val heatmapOverlay = GroundOverlay() + heatmapOverlay.setImage(bitmap) + heatmapOverlay.transparency = 0.3f + heatmapOverlay.setPosition(tile.topLeft.toGeoPoint(), tile.bottomRight.toGeoPoint()) + withContext(Dispatchers.Main) { + mapUpdate.map.overlays.add(0, heatmapOverlay) + mapUpdate.map.invalidate() + } + tilesState.tiles[tile] = TilesData(tile, heatmapOverlay, positionsForTile) + } + } + } + + Timber.tag(TAG).d("All tiles rendered") + loadingJob.cancel() + } + } else { + mapUpdate.map.overlays.removeAll { it is GroundOverlay } + } + viewModel.loadingHeatmap = false + mapUpdate.map.invalidate() + } + + private fun updateMapCamera(mapUpdate: MapUpdate) { + when (val cameraConfig = mapUpdate.cameraState) { + is DeviceDetailsViewModel.MapCameraState.SinglePoint -> { + Timber.d(cameraConfig.toString()) + val point = GeoPoint(cameraConfig.location.lat, cameraConfig.location.lng) + mapUpdate.map.controller.animateTo( + point, + cameraConfig.zoom, + if (cameraConfig.withAnimation) MapConfig.MAP_ANIMATION else MapConfig.MAP_NO_ANIMATION + ) + mapUpdate.map.invalidate() + } + + is DeviceDetailsViewModel.MapCameraState.MultiplePoints -> { + Timber.d(cameraConfig.toString()) + mapUpdate.map.post { + mapUpdate.map.zoomToBoundingBox( + BoundingBox.fromGeoPoints(cameraConfig.points.map { GeoPoint(it.lat, it.lng) }), + cameraConfig.withAnimation, + mapUpdate.map.context.dpToPx(16f), + MapConfig.MAX_MAP_ZOOM, + MapConfig.MAP_ANIMATION, + ) + } + mapUpdate.map.invalidate() + } + } + } + private fun refreshMap( mapUpdate: MapUpdate, batchProcessor: AsyncBatchProcessor, mapColorScheme: MapColorScheme, pointsStyle: DeviceDetailsViewModel.PointsStyle, ) { - when (pointsStyle) { DeviceDetailsViewModel.PointsStyle.MARKERS -> { batchProcessor.process(mapUpdate.points, mapUpdate.map) @@ -915,8 +1165,8 @@ object DeviceDetailsScreen { DeviceDetailsViewModel.PointsStyle.PATH -> { batchProcessor.cancel() - mapUpdate.map.overlays.clear() - val points = mapUpdate.points.map { GeoPoint(it.lat, it.lng) } + mapUpdate.map.overlays.clearPoints() + val points = mapUpdate.points.map { it.toGeoPoint() } val polyline = Polyline(mapUpdate.map).apply { this.setPoints(points) this.outlinePaint.apply { @@ -942,34 +1192,16 @@ object DeviceDetailsScreen { mapUpdate.map.overlays.add(fastPointOverlay) mapUpdate.map.invalidate() } - } - - when (val cameraConfig = mapUpdate.cameraState) { - is DeviceDetailsViewModel.MapCameraState.SinglePoint -> { - Timber.d(cameraConfig.toString()) - val point = GeoPoint(cameraConfig.location.lat, cameraConfig.location.lng) - mapUpdate.map.controller.animateTo( - point, - cameraConfig.zoom, - if (cameraConfig.withAnimation) MapConfig.MAP_ANIMATION else MapConfig.MAP_NO_ANIMATION - ) - mapUpdate.map.invalidate() - } - - is DeviceDetailsViewModel.MapCameraState.MultiplePoints -> { - Timber.d(cameraConfig.toString()) - mapUpdate.map.post { - mapUpdate.map.zoomToBoundingBox( - BoundingBox.fromGeoPoints(cameraConfig.points.map { GeoPoint(it.lat, it.lng) }), - cameraConfig.withAnimation, - mapUpdate.map.context.dpToPx(16f), - MapConfig.MAX_MAP_ZOOM, - MapConfig.MAP_ANIMATION, - ) - } + DeviceDetailsViewModel.PointsStyle.HIDE_MARKERS -> { + batchProcessor.cancel() + mapUpdate.map.overlays.clearPoints() mapUpdate.map.invalidate() } } } + + private fun MutableList.clearPoints() { + this.removeAll { it !is GroundOverlay } + } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt index b4e92e7..45207b1 100644 --- a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt @@ -64,6 +64,7 @@ class DeviceDetailsViewModel( var cameraState: MapCameraState by mutableStateOf(DEFAULT_MAP_CAMERA_STATE) var historyPeriod by mutableStateOf(DEFAULT_HISTORY_PERIOD) var markersInLoadingState by mutableStateOf(false) + var loadingHeatmap by mutableStateOf(false) var onlineStatusData: OnlineStatus? by mutableStateOf(null) var pointsStyle: PointsStyle by mutableStateOf(DEFAULT_POINTS_STYLE) var rawData: List> by mutableStateOf(listOf()) @@ -72,6 +73,10 @@ class DeviceDetailsViewModel( private var connectionJob: Job? = null var matadataIsFetching by mutableStateOf(false) + var mapExpanded: Boolean by mutableStateOf(false) + + var useHeatmap: Boolean by mutableStateOf(true) + sealed class ConnectionStatus(@StringRes val statusRes: Int) { data class CONNECTED(val gatt: BluetoothGatt) : ConnectionStatus(R.string.device_details_status_connected) data object CONNECTING : ConnectionStatus(R.string.device_details_status_connecting) @@ -361,6 +366,10 @@ class DeviceDetailsViewModel( pointsStyle = PointsStyle.PATH } + if (fetched.size > MAX_POINTS_FOR_HEATMAP) { + useHeatmap = false + } + pointsState = fetched updateCameraPosition(pointsState, currentLocation) } @@ -438,6 +447,7 @@ class DeviceDetailsViewModel( enum class PointsStyle(@StringRes val displayNameRes: Int) { MARKERS(R.string.device_history_pint_style_markers), PATH(R.string.device_history_pint_style_path), + HIDE_MARKERS(R.string.device_history_pint_style_hide_markers), } sealed interface MapCameraState { @@ -461,6 +471,7 @@ class DeviceDetailsViewModel( companion object { private const val DESCRIPTOR_CHARACTERISTIC_USER_DESCRIPTION = "00002901-0000-1000-8000-00805f9b34fb" private const val MAX_POINTS_FOR_MARKERS = 5_000 + private const val MAX_POINTS_FOR_HEATMAP = 30_000 private const val HISTORY_PERIOD_DAY = 24 * 60 * 60 * 1000L // 24 hours private const val HISTORY_PERIOD_WEEK = 7 * 24 * 60 * 60 * 1000L // 1 week private const val HISTORY_PERIOD_MONTH = 31 * 24 * 60 * 60 * 1000L // 1 month diff --git a/app/src/main/java/f/cking/software/utils/ComposeUtils.kt b/app/src/main/java/f/cking/software/utils/ComposeUtils.kt new file mode 100644 index 0000000..378df48 --- /dev/null +++ b/app/src/main/java/f/cking/software/utils/ComposeUtils.kt @@ -0,0 +1,6 @@ +package f.cking.software.utils + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.IntSize + +val ScreenSizeLocal = compositionLocalOf { IntSize(0, 0) } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/utils/ext.kt b/app/src/main/java/f/cking/software/utils/ext.kt index c24c67a..2bafa36 100644 --- a/app/src/main/java/f/cking/software/utils/ext.kt +++ b/app/src/main/java/f/cking/software/utils/ext.kt @@ -3,6 +3,7 @@ package f.cking.software import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.location.Location import android.net.Uri import android.util.Base64 import android.util.TypedValue @@ -22,6 +23,9 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.osmdroid.api.IGeoPoint +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.Projection import timber.log.Timber import java.security.MessageDigest import java.time.Instant @@ -227,4 +231,19 @@ operator fun AtomicInteger.getValue(thisRef: Any?, property: kotlin.reflect.KPro this.get() operator fun AtomicInteger.setValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: Int) = - this.set(value) \ No newline at end of file + this.set(value) + +fun IGeoPoint.toLocation(): Location { + return Location("").apply { + latitude = this@toLocation.latitude + longitude = this@toLocation.longitude + } +} + +fun Projection.topLeft(): IGeoPoint { + return GeoPoint(boundingBox.latNorth, boundingBox.lonWest) +} + +fun Projection.bottomRight(): IGeoPoint { + return GeoPoint(boundingBox.latSouth, boundingBox.lonEast) +} \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt new file mode 100644 index 0000000..84f4e63 --- /dev/null +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -0,0 +1,677 @@ +package f.cking.software.utils.graphic + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.location.Location +import androidx.collection.LruCache +import androidx.core.graphics.createBitmap +import kotlin.math.abs +import kotlin.math.atan +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * Most of the class is AI generated + */ +object HeatMapBitmapFactory { + + data class Position(val location: Location, val radiusMeters: Float) + data class Tile(val topLeft: Location, val bottomRight: Location) { + + /** + * True if this tile intersects (collides) with [other]. + * + * @param inclusiveEdges if true, touching edges count as intersecting. + * if false, requires positive-area overlap. + */ + fun intersects(other: Tile, inclusiveEdges: Boolean = true): Boolean { + val aTop = topLeft.latitude + val aBottom = bottomRight.latitude + val aLeft = topLeft.longitude + val aRight = bottomRight.longitude + + val bTop = other.topLeft.latitude + val bBottom = other.bottomRight.latitude + val bLeft = other.topLeft.longitude + val bRight = other.bottomRight.longitude + + return if (inclusiveEdges) { + // Overlap in lat AND overlap in lng, allowing equality at edges. + aLeft <= bRight && + aRight >= bLeft && + aBottom <= bTop && + aTop >= bBottom + } else { + // Strict overlap (positive area) + aLeft < bRight && + aRight > bLeft && + aBottom < bTop && + aTop > bBottom + } + } + + /** + * True if this tile fully contains [other] (including borders). + */ + fun contains(other: Tile): Boolean { + val aTop = topLeft.latitude + val aBottom = bottomRight.latitude + val aLeft = topLeft.longitude + val aRight = bottomRight.longitude + + val bTop = other.topLeft.latitude + val bBottom = other.bottomRight.latitude + val bLeft = other.topLeft.longitude + val bRight = other.bottomRight.longitude + + return bLeft >= aLeft && + bRight <= aRight && + bTop <= aTop && + bBottom >= aBottom + } + + fun contains(location: Location, paddingMeters: Double): Boolean { + require(paddingMeters >= 0) + + val latTop = topLeft.latitude + val latBottom = bottomRight.latitude + val lngLeft = topLeft.longitude + val lngRight = bottomRight.longitude + + // Quickly reject impossible lat (even before meter expansion). + val locLat = location.latitude + val locLng = location.longitude + + // If no padding, fast path: + if (paddingMeters <= 0.0) { + return locLat <= latTop && + locLat >= latBottom && + locLng >= lngLeft && + locLng <= lngRight + } + + // Expand tile bounds by paddingMeters in lat/lng + val centerLat = (latTop + latBottom) / 2.0 + val mPerDegLat = 111_320.0 + val mPerDegLng = 111_320.0 * kotlin.math.cos(Math.toRadians(centerLat)) + + val dLat = paddingMeters / mPerDegLat + val dLng = paddingMeters / mPerDegLng + + val paddedTop = latTop + dLat + val paddedBottom = latBottom - dLat + val paddedLeft = lngLeft - dLng + val paddedRight = lngRight + dLng + + return locLat <= paddedTop && + locLat >= paddedBottom && + locLng >= paddedLeft && + locLng <= paddedRight + } + } + + private fun generateTileGradientBitmapFastNoNormalize( + positions: List, + tile: Tile, + widthPx: Int, + downsample: Int, + blurSigmaPx: Float, + ): Bitmap { + fun metersPerDegLat(): Double = 111_320.0 + fun metersPerDegLng(atLatDeg: Double): Double = + 111_320.0 * cos(Math.toRadians(atLatDeg)) + + val latTop = tile.topLeft.latitude + val latBottom = tile.bottomRight.latitude + val lngLeft = tile.topLeft.longitude + val lngRight = tile.bottomRight.longitude + val centerLat = (latTop + latBottom) / 2.0 + + val tileWidthMeters = abs(lngRight - lngLeft) * metersPerDegLng(centerLat) + val tileHeightMeters = abs(latTop - latBottom) * metersPerDegLat() + val heightPx = max(1, round(widthPx * (tileHeightMeters / tileWidthMeters)).toInt()) + + val ds = downsample.coerceAtLeast(1) + val wL = max(1, widthPx / ds) + val hL = max(1, heightPx / ds) + val sizeL = wL * hL + + val pxPerMeterX_L = wL / tileWidthMeters + val pxPerMeterY_L = hL / tileHeightMeters + val pxPerMeter_L = min(pxPerMeterX_L, pxPerMeterY_L) + + fun latLngToPixelLow(lat: Double, lng: Double): Pair { + val xNorm = (lng - lngLeft) / (lngRight - lngLeft) + val yNorm = (latTop - lat) / (latTop - latBottom) + return (xNorm * wL).toFloat() to (yNorm * hL).toFloat() + } + + fun idxL(x: Int, y: Int) = y * wL + x + + val intensityL = FloatArray(sizeL) + + fun smoothstep(t: Float): Float { + val x = t.coerceIn(0f, 1f) + return x * x * (3f - 2f * x) + } + + val kernelCache = HashMap() + fun getKernel(rPx: Int): FloatArray { + return kernelCache.getOrPut(rPx) { + val d = 2 * rPx + 1 + val k = FloatArray(d * d) + val r = rPx.toFloat() + val r2 = r * r + var i = 0 + for (yy in -rPx..rPx) { + val dy = yy.toFloat() + 0.5f + val dy2 = dy * dy + for (xx in -rPx..rPx) { + val dx = xx.toFloat() + 0.5f + val d2 = dx * dx + dy2 + k[i++] = if (d2 <= r2) { + val dist = sqrt(d2) + smoothstep(1f - dist / r) + } else 0f + } + } + k + } + } + + // MAX-union stamping + for (pos in positions) { + val (cx, cy) = latLngToPixelLow(pos.location.latitude, pos.location.longitude) + val rPxF = max(1f, pos.radiusMeters * pxPerMeter_L.toFloat()) + val rPx = rPxF.roundToInt().coerceAtLeast(1) + + val kernel = getKernel(rPx) + val d = 2 * rPx + 1 + + val x0 = floor(cx - rPx).toInt() + val y0 = floor(cy - rPx).toInt() + + for (ky in 0 until d) { + val y = y0 + ky + if (y !in 0 until hL) continue + val rowBase = idxL(0, y) + val kRowBase = ky * d + for (kx in 0 until d) { + val x = x0 + kx + if (x !in 0 until wL) continue + val v = kernel[kRowBase + kx] + if (v <= 0f) continue + val id = rowBase + x + if (v > intensityL[id]) intensityL[id] = v + } + } + } + + if ((intensityL.maxOrNull() ?: 0f) <= 0f) { + return createBitmap(widthPx, heightPx) + } + + val blurredL = gaussianBlurSeparable(intensityL, wL, hL, blurSigmaPx) + + // Upscale + colorize (proper gradient) + val out = IntArray(widthPx * heightPx) + + fun sampleLow(x: Float, y: Float): Float { + val x0i = floor(x).toInt().coerceIn(0, wL - 1) + val y0i = floor(y).toInt().coerceIn(0, hL - 1) + val x1i = (x0i + 1).coerceIn(0, wL - 1) + val y1i = (y0i + 1).coerceIn(0, hL - 1) + val fx = x - x0i + val fy = y - y0i + + val v00 = blurredL[idxL(x0i, y0i)] + val v10 = blurredL[idxL(x1i, y0i)] + val v01 = blurredL[idxL(x0i, y1i)] + val v11 = blurredL[idxL(x1i, y1i)] + + val vx0 = v00 * (1 - fx) + v10 * fx + val vx1 = v01 * (1 - fx) + v11 * fx + return vx0 * (1 - fy) + vx1 * fy + } + + // visibility boost so lone blobs aren't too faint + val alphaScale = 1.6f + + var iOut = 0 + for (y in 0 until heightPx) { + val yL = y.toFloat() / ds + for (x in 0 until widthPx) { + val xL = x.toFloat() / ds + val vRaw = sampleLow(xL, yL).coerceIn(0f, 1f) + + val v = min(1f, vRaw * alphaScale) + + val a = (v * 255f).toInt() + val r = ((1f - v) * 255f).toInt() + val g = (v * 255f).toInt() + val b = 0 + + out[iOut++] = (a shl 24) or (r shl 16) or (g shl 8) or b + } + } + + return Bitmap.createBitmap(out, widthPx, heightPx, Bitmap.Config.ARGB_8888) + } + + /** + * Fast separable Gaussian blur for a float buffer. + * Edge handling: clamp. + */ + private fun gaussianBlurSeparable( + src: FloatArray, + w: Int, + h: Int, + sigma: Float + ): FloatArray { + if (sigma <= 0.1f) return src.copyOf() + + val radius = ceil(3f * sigma).toInt().coerceAtLeast(1) + val kernel = FloatArray(2 * radius + 1) + var sum = 0f + val twoSigma2 = 2f * sigma * sigma + for (i in -radius..radius) { + val v = exp(-(i * i) / twoSigma2) + kernel[i + radius] = v + sum += v + } + // normalize + for (i in kernel.indices) kernel[i] /= sum + + val tmp = FloatArray(w * h) + val dst = FloatArray(w * h) + + fun idx(x: Int, y: Int) = y * w + x + + // horizontal + for (y in 0 until h) { + for (x in 0 until w) { + var acc = 0f + for (k in -radius..radius) { + val sx = (x + k).coerceIn(0, w - 1) + acc += src[idx(sx, y)] * kernel[k + radius] + } + tmp[idx(x, y)] = acc + } + } + + // vertical + for (y in 0 until h) { + for (x in 0 until w) { + var acc = 0f + for (k in -radius..radius) { + val sy = (y + k).coerceIn(0, h - 1) + acc += tmp[idx(x, sy)] * kernel[k + radius] + } + dst[idx(x, y)] = acc + } + } + + return dst + } + + private fun computeWidthForRenderTile(core: Tile, render: Tile, widthPxCore: Int): Int { + // keep px-per-meter consistent between core and render + val metersPerDegLng = { lat: Double -> + 111_320.0 * cos(Math.toRadians(lat)) + } + val latCoreC = (core.topLeft.latitude + core.bottomRight.latitude) / 2.0 + val latRenderC = (render.topLeft.latitude + render.bottomRight.latitude) / 2.0 + + val coreWidthM = + abs(core.bottomRight.longitude - core.topLeft.longitude) * metersPerDegLng(latCoreC) + val renderWidthM = + abs(render.bottomRight.longitude - render.topLeft.longitude) * metersPerDegLng(latRenderC) + + val pxPerM = widthPxCore / coreWidthM + return max(1, round(renderWidthM * pxPerM).toInt()) + } + + fun paddedRenderTile(core: Tile, paddingMeters: Double): Tile { + if (paddingMeters <= 0.0) return core + + val latTop = core.topLeft.latitude + val latBottom = core.bottomRight.latitude + val lngLeft = core.topLeft.longitude + val lngRight = core.bottomRight.longitude + + val centerLat = (latTop + latBottom) / 2.0 + val mPerDegLat = 111_320.0 + val mPerDegLng = 111_320.0 * kotlin.math.cos(Math.toRadians(centerLat)) + + val padLat = paddingMeters / mPerDegLat + val padLng = paddingMeters / mPerDegLng + + fun make(lat: Double, lng: Double) = + Location("tile_pad").apply { latitude = lat; longitude = lng } + + val paddedTopLeft = make(latTop + padLat, lngLeft - padLng) + val paddedBottomRight = make(latBottom - padLat, lngRight + padLng) + + return Tile(paddedTopLeft, paddedBottomRight) + } + + fun filterPointsForTile( + positions: List, + renderTile: Tile, + extraMarginMeters: Double + ): List { + val latTop = renderTile.topLeft.latitude + val latBottom = renderTile.bottomRight.latitude + val lngLeft = renderTile.topLeft.longitude + val lngRight = renderTile.bottomRight.longitude + + // expand bounds by extraMarginMeters (cheap approx) + val centerLat = (latTop + latBottom) / 2.0 + val mPerDegLat = 111_320.0 + val mPerDegLng = 111_320.0 * kotlin.math.cos(Math.toRadians(centerLat)) + val dLat = extraMarginMeters / mPerDegLat + val dLng = extraMarginMeters / mPerDegLng + + val top = latTop + dLat + val bottom = latBottom - dLat + val left = lngLeft - dLng + val right = lngRight + dLng + + return positions.filter { p -> + val lat = p.location.latitude + val lng = p.location.longitude + lat in bottom..top && lng in left..right + } + } + + private data class BitmapCacheKey( + val positionsAll: List, + val coreTile: Tile, + val widthPxCore: Int, + val renderPaddingMeters: Double, + val downsample: Int = 5, + val blurSigmaPxLow: Float = 4f, + val debugBorderPx: Int = 0 + ) + + private val bitmapCache = LruCache(maxSize = 100) + + private var simpleDebugBitmap: Bitmap? = null + + fun simpleDebugBitmap( + coreTile: Tile, + widthPxCore: Int, + renderPaddingMeters: Double, + ): Bitmap { + + if (simpleDebugBitmap != null) return simpleDebugBitmap!! + + fun metersPerDegLat(): Double = 111_320.0 + fun metersPerDegLng(atLatDeg: Double): Double = + 111_320.0 * cos(Math.toRadians(atLatDeg)) + + val latTop = coreTile.topLeft.latitude + val latBottom = coreTile.bottomRight.latitude + val lngLeft = coreTile.topLeft.longitude + val lngRight = coreTile.bottomRight.longitude + val centerLat = (latTop + latBottom) / 2.0 + + // 1) Build render tile (padded) + val renderTile = paddedRenderTile(coreTile, renderPaddingMeters) + val widthPx = computeWidthForRenderTile(coreTile, renderTile, widthPxCore) + + val tileWidthMeters = abs(lngRight - lngLeft) * metersPerDegLng(centerLat) + val tileHeightMeters = abs(latTop - latBottom) * metersPerDegLat() + val heightPx = max(1, round(widthPx * (tileHeightMeters / tileWidthMeters)).toInt()) + + val out = IntArray(widthPx * heightPx) + val bmpPadded = Bitmap.createBitmap(out, widthPx, heightPx, Bitmap.Config.ARGB_8888) + + val cropRect = computeCoreCropRectPx(coreTile, renderTile, bmpPadded.width, bmpPadded.height) + + val emptyBitmap = Bitmap.createBitmap( + bmpPadded, + cropRect.left, + cropRect.top, + cropRect.width(), + cropRect.height() + ).copy(Bitmap.Config.ARGB_8888, true) + + drawDebugBorder(emptyBitmap, 2) + simpleDebugBitmap = emptyBitmap + + return simpleDebugBitmap!! + } + + fun generateTileGradientBitmapFastSeamless( + positionsAll: List, + coreTile: Tile, + widthPxCore: Int, + renderPaddingMeters: Double, + downsample: Int = 4, + blurSigmaPxLow: Float = 4f, + debugBorderPx: Int = 0 + ): Bitmap { + require(widthPxCore > 0) + + val key = BitmapCacheKey(positionsAll, coreTile, widthPxCore, renderPaddingMeters, downsample, blurSigmaPxLow, debugBorderPx) + + val cachedBitmap = bitmapCache[key] + if (cachedBitmap != null) return cachedBitmap + + // 1) Build render tile (padded) + val renderTile = paddedRenderTile(coreTile, renderPaddingMeters) + + // 2) Filter points relevant to padded area (huge speed win) + val maxRadius = positionsAll.maxOfOrNull { it.radiusMeters.toDouble() } ?: 0.0 + val positions = filterPointsForTile(positionsAll, renderTile, extraMarginMeters = maxRadius) + + // 3) Render padded bitmap + val bmpPadded = generateTileGradientBitmapFastNoNormalize( + positions = positions, + tile = renderTile, + widthPx = computeWidthForRenderTile(coreTile, renderTile, widthPxCore), + downsample = downsample, + blurSigmaPx = blurSigmaPxLow, + ) + + // 4) Crop padded bitmap back to core area + val cropRect = computeCoreCropRectPx(coreTile, renderTile, bmpPadded.width, bmpPadded.height) + + val immutableBitmap = Bitmap.createBitmap( + bmpPadded, + cropRect.left, + cropRect.top, + cropRect.width(), + cropRect.height() + ) + + val result = if (debugBorderPx == 0) { + immutableBitmap + } else { + val bmpCore = immutableBitmap.copy(Bitmap.Config.ARGB_8888, true) + drawDebugBorder(bmpCore, debugBorderPx) + bmpCore + } + bitmapCache.put(key, result) + return result + } + + private fun computeCoreCropRectPx( + core: Tile, + render: Tile, + renderWidthPx: Int, + renderHeightPx: Int + ): Rect { + // map core bounds into render pixel space linearly + val latTopR = render.topLeft.latitude + val latBottomR = render.bottomRight.latitude + val lngLeftR = render.topLeft.longitude + val lngRightR = render.bottomRight.longitude + + fun xPx(lng: Double): Int { + val t = (lng - lngLeftR) / (lngRightR - lngLeftR) + return (t * renderWidthPx).roundToInt() + } + + fun yPx(lat: Double): Int { + val t = (latTopR - lat) / (latTopR - latBottomR) + return (t * renderHeightPx).roundToInt() + } + + val left = xPx(core.topLeft.longitude).coerceIn(0, renderWidthPx) + val right = xPx(core.bottomRight.longitude).coerceIn(0, renderWidthPx) + val top = yPx(core.topLeft.latitude).coerceIn(0, renderHeightPx) + val bottom = yPx(core.bottomRight.latitude).coerceIn(0, renderHeightPx) + + return Rect( + min(left, right), + min(top, bottom), + max(left, right), + max(top, bottom) + ) + } + + fun buildTilesWithRenderPaddingStable( + points: List, + tileSizeMeters: Double, + paddingMeters: Double = 0.0 + ): List { + if (points.isEmpty() || tileSizeMeters <= 0.0 || paddingMeters < 0.0) return emptyList() + + val N = tileSizeMeters + val P = paddingMeters + + // Web Mercator constants (EPSG:3857) + val R = 6378137.0 + val MAX_LAT = 85.05112878 + + fun clampLat(lat: Double) = lat.coerceIn(-MAX_LAT, MAX_LAT) + + fun mercatorX(lngDeg: Double): Double { + val lonRad = Math.toRadians(lngDeg) + return R * lonRad + } + + fun mercatorY(latDeg: Double): Double { + val latRad = Math.toRadians(clampLat(latDeg)) + return R * ln(tan(Math.PI / 4.0 + latRad / 2.0)) + } + + fun inverseMercator(x: Double, y: Double): Pair { + val lonRad = x / R + val latRad = 2.0 * atan(exp(y / R)) - Math.PI / 2.0 + return Math.toDegrees(latRad) to Math.toDegrees(lonRad) + } + + data class IJ(val i: Int, val j: Int) + + val tilesToRender = HashSet(points.size * 4) + + fun outwardSteps(distToEdge: Double): Int { + if (P <= distToEdge) return 0 + val extra = P - distToEdge + return ceil(extra / N).toInt().coerceAtLeast(1) + } + + for (p in points) { + val x = mercatorX(p.longitude) + val y = mercatorY(p.latitude) + + val i0 = floor(x / N).toInt() + val j0 = floor(y / N).toInt() + tilesToRender.add(IJ(i0, j0)) + + val lx = x - i0 * N // [0, N) + val ly = y - j0 * N // [0, N) + + val distLeft = lx + val distRight = N - lx + val distTop = ly + val distBottom = N - ly + + val leftSteps = outwardSteps(distLeft) + val rightSteps = outwardSteps(distRight) + val topSteps = outwardSteps(distTop) + val bottomSteps = outwardSteps(distBottom) + + // Side neighbors + for (s in 1..leftSteps) tilesToRender.add(IJ(i0 - s, j0)) + for (s in 1..rightSteps) tilesToRender.add(IJ(i0 + s, j0)) + for (s in 1..topSteps) tilesToRender.add(IJ(i0, j0 - s)) + for (s in 1..bottomSteps) tilesToRender.add(IJ(i0, j0 + s)) + + // Corner neighbors + for (sx in 1..leftSteps) { + for (sy in 1..topSteps) tilesToRender.add(IJ(i0 - sx, j0 - sy)) + for (sy in 1..bottomSteps) tilesToRender.add(IJ(i0 - sx, j0 + sy)) + } + for (sx in 1..rightSteps) { + for (sy in 1..topSteps) tilesToRender.add(IJ(i0 + sx, j0 - sy)) + for (sy in 1..bottomSteps) tilesToRender.add(IJ(i0 + sx, j0 + sy)) + } + } + + fun makeLocation(lat: Double, lng: Double) = + Location("tile").apply { + latitude = lat + longitude = lng + } + + val result = ArrayList(tilesToRender.size) + for ((i, j) in tilesToRender) { + val x0 = i * N + val y0 = j * N + val x1 = x0 + N + val y1 = y0 + N + + // In Mercator, +y is north. Tile top-left uses (x0, y1). + val (latTop, lngLeft) = inverseMercator(x0, y1) + val (latBottom, lngRight) = inverseMercator(x1, y0) + + result.add( + Tile( + topLeft = makeLocation(latTop, lngLeft), + bottomRight = makeLocation(latBottom, lngRight) + ) + ) + } + + return result + } + + private fun drawDebugBorder( + bitmap: Bitmap, + borderPx: Int = 4, + color: Int = 0xFFFF0000.toInt() // red + ) { + if (borderPx <= 0) return + + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + style = Paint.Style.STROKE + strokeWidth = borderPx.toFloat() + } + + val half = borderPx / 2f + canvas.drawRect( + half, + half, + bitmap.width - half, + bitmap.height - half, + paint + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6d1215..9969115 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,6 +251,8 @@ History style Markers Path + Hide markers + Range heatmap %s ago lifetime: %1$s | last update: %2$s ago lifetime: %1$s @@ -288,6 +290,7 @@ Disconnect Read Metadata + Expand map Map disclaimer The map does not display the device’s actual location; it only records your smartphone’s coordinates at the moment of scanning. The location marker reflects where the device was detected, not where it truly is. The map is not designed to locate devices, but to show where they were previously observed and how their movement relates to your own.