From 70afbc372f9f65504bb424c123dad3c9fe9509ba Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Sat, 22 Nov 2025 14:29:00 -0800 Subject: [PATCH 1/7] Add base algorythms for heatmap --- .../cking/software/ui/AsyncBatchProcessor.kt | 3 +- .../ui/devicedetails/DeviceDetailsScreen.kt | 9 +- .../devicedetails/DeviceDetailsViewModel.kt | 2 + .../utils/graphic/HeatMapBitmapFactory.kt | 176 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt 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 fb1e1012..1c0bb0a6 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/devicedetails/DeviceDetailsScreen.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt index 6dfca64f..b66c0662 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 @@ -87,6 +87,7 @@ 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.ListItem import f.cking.software.utils.graphic.RadarIcon import f.cking.software.utils.graphic.RoundedBox @@ -104,6 +105,7 @@ 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.Polyline import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlay @@ -907,7 +909,6 @@ object DeviceDetailsScreen { mapColorScheme: MapColorScheme, pointsStyle: DeviceDetailsViewModel.PointsStyle, ) { - when (pointsStyle) { DeviceDetailsViewModel.PointsStyle.MARKERS -> { batchProcessor.process(mapUpdate.points, mapUpdate.map) @@ -942,6 +943,12 @@ object DeviceDetailsScreen { mapUpdate.map.overlays.add(fastPointOverlay) mapUpdate.map.invalidate() } + + DeviceDetailsViewModel.PointsStyle.HEAT_MAP -> { + val heatmapOverlay = GroundOverlay() + val tile = HeatMapBitmapFactory.Tile() + heatmapOverlay.setImage() + } } 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 b4e92e7d..1aee6b81 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 @@ -438,6 +438,8 @@ 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), + + HEAT_MAP(R.string.device_history_pint_style_heatmap), } sealed interface MapCameraState { 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 00000000..3d7ac596 --- /dev/null +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -0,0 +1,176 @@ +package f.cking.software.utils.graphic + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RadialGradient +import android.graphics.Shader +import android.location.Location +import androidx.core.graphics.component1 +import androidx.core.graphics.component2 +import androidx.core.graphics.createBitmap +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round + +object HeatMapBitmapFactory { + + data class Position(val location: Location, val radiusMeters: Float) + data class Tile(val topLeft: Location, val bottomRight: Location) + + /** + * Generates a heatmap-like bitmap for the given tile. + * + * Colors: + * - Red at center + * - Green at radius edge + * - Transparent outside + * + * @param positions points with radius in meters + * @param tile geographic bounds (topLeft has bigger lat, smaller lng) + * @param widthPx desired bitmap width in pixels + * @param additiveBlend if true, overlaps brighten via ADD; else normal SRC_OVER alpha + */ + suspend fun generateTileGradientBitmap( + positions: List, + tile: Tile, + widthPx: Int, + additiveBlend: Boolean = false + ): Bitmap { + require(widthPx > 0) + + // --- Simple equirectangular meters-per-degree approximation --- + 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 tileCenterLat = (latTop + latBottom) / 2.0 + val mPerDegLat = metersPerDegLat() + val mPerDegLng = metersPerDegLng(tileCenterLat) + + val tileWidthMeters = abs(lngRight - lngLeft) * mPerDegLng + val tileHeightMeters = abs(latTop - latBottom) * mPerDegLat + + if (tileWidthMeters <= 0.0 || tileHeightMeters <= 0.0) { + return createBitmap(widthPx, widthPx) + } + + val heightPx = max(1, round(widthPx * (tileHeightMeters / tileWidthMeters)).toInt()) + + val bitmap = createBitmap(widthPx, heightPx) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + val pxPerMeterX = widthPx / tileWidthMeters + val pxPerMeterY = heightPx / tileHeightMeters + val pxPerMeter = min(pxPerMeterX, pxPerMeterY) // keep circles circular + + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + isDither = true + } + if (additiveBlend) { + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.ADD) + } + + fun latLngToPixel(lat: Double, lng: Double): PointF { + val xNorm = (lng - lngLeft) / (lngRight - lngLeft) + val yNorm = (latTop - lat) / (latTop - latBottom) // top->0, bottom->1 + return PointF( + (xNorm * widthPx).toFloat(), + (yNorm * heightPx).toFloat() + ) + } + + val red = Color.argb(255, 255, 0, 0) + val green = Color.argb(255, 0, 255, 0) + val transparent = Color.TRANSPARENT + + for (pos in positions) { + val loc = pos.location + val (px, py) = latLngToPixel(loc.latitude, loc.longitude) + val radiusPx = max(1f, pos.radiusMeters * pxPerMeter.toFloat()) + + // Cheap cull if fully outside + if (px + radiusPx < 0 || px - radiusPx > widthPx || + py + radiusPx < 0 || py - radiusPx > heightPx + ) continue + + paint.shader = RadialGradient( + px, py, radiusPx, + intArrayOf(red, green, transparent), + floatArrayOf(0f, 0.98f, 1f), + Shader.TileMode.CLAMP + ) + canvas.drawCircle(px, py, radiusPx, paint) + } + + paint.shader = null + paint.xfermode = null + + return bitmap + } + + /** + * Computes a minimal Tile that contains all locations plus a padding margin in meters. + * + * Uses local meters-per-degree approximations. Suitable for typical map region sizes. + */ + fun computeBoundingTile( + points: List, + paddingMeters: Double + ): Tile { + require(points.isNotEmpty()) + + // Extract numeric bounds + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLng = Double.POSITIVE_INFINITY + var maxLng = Double.NEGATIVE_INFINITY + + for (p in points) { + val lat = p.latitude + val lng = p.longitude + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + } + + // Approximate meters per degree using center latitude + val centerLat = (minLat + maxLat) / 2.0 + val mPerDegLat = 111_320.0 + val mPerDegLng = 111_320.0 * cos(Math.toRadians(centerLat)) + + // Convert meter padding → degree padding + val padLat = paddingMeters / mPerDegLat + val padLng = paddingMeters / mPerDegLng + + val paddedMinLat = minLat - padLat + val paddedMaxLat = maxLat + padLat + val paddedMinLng = minLng - padLng + val paddedMaxLng = maxLng + padLng + + fun loc(lat: Double, lng: Double): Location = + Location("bounding").apply { + latitude = lat + longitude = lng + } + + val topLeft = loc(paddedMaxLat, paddedMinLng) + val bottomRight = loc(paddedMinLat, paddedMaxLng) + + return Tile(topLeft, bottomRight) + } +} \ 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 b6d12152..f1705d4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,6 +251,7 @@ History style Markers Path + Heat map %s ago lifetime: %1$s | last update: %2$s ago lifetime: %1$s From 48cd6ace0becdda8718295ca0e8dbb1223c077cb Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Sun, 23 Nov 2025 21:22:44 -0800 Subject: [PATCH 2/7] Implement heatmap logic --- .../software/domain/model/LocationModel.kt | 17 + .../ui/devicedetails/DeviceDetailsScreen.kt | 145 +++-- .../devicedetails/DeviceDetailsViewModel.kt | 6 +- .../utils/graphic/HeatMapBitmapFactory.kt | 550 ++++++++++++++---- app/src/main/res/values/strings.xml | 2 +- 5 files changed, 578 insertions(+), 142 deletions(-) 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 51e5b9b7..0402d98b 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/devicedetails/DeviceDetailsScreen.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt index b66c0662..57a33588 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 @@ -78,8 +78,11 @@ 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.ui.AsyncBatchProcessor import f.cking.software.ui.map.MapView import f.cking.software.ui.tagdialog.TagDialog @@ -92,11 +95,14 @@ 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.isActive +import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.osmdroid.events.MapListener @@ -107,6 +113,7 @@ 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 @@ -225,7 +232,7 @@ object DeviceDetailsScreen { LocationHistory( modifier = Modifier .fillMaxWidth() - .height(400.dp), + .height(500.dp), deviceData = deviceData, viewModel = viewModel, isMoving = isMoving, @@ -646,6 +653,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, @@ -721,6 +741,7 @@ object DeviceDetailsScreen { } } if (mapIsReady) { + HeatMapSettings(viewModel) PointsStyle(viewModel) HistoryPeriod(deviceData = deviceData, viewModel = viewModel) } @@ -776,7 +797,8 @@ 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, ) @@ -815,7 +837,7 @@ object DeviceDetailsScreen { }, onStart = { map -> isLoading.invoke(true) - map.overlays.clear() + map.overlays.clearPoints() map.invalidate() }, onComplete = { map -> @@ -876,11 +898,19 @@ object DeviceDetailsScreen { ) 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 mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView!!) + LaunchedEffect(mapView, viewModel.pointsState, viewModel.pointsStyle) { refreshMap(mapUpdate, batchProcessor, mapColorScheme, viewModel.pointsStyle) } + + LaunchedEffect(mapView, viewModel.pointsState) { + updateMapCamera(mapUpdate) + } + + LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap) { + renderHeatmap(mapUpdate, viewModel) + } } } @@ -903,6 +933,69 @@ object DeviceDetailsScreen { val pointColor: Color, ) + + private const val PADDING_METERS = 50.0 + private const val TILE_SIZE_METERS = 300.0 + + private suspend fun renderHeatmap(mapUpdate: MapUpdate, viewModel: DeviceDetailsViewModel) { + if (viewModel.useHeatmap) { + val locations = mapUpdate.points.map { it.toLocation() } + val tiles = HeatMapBitmapFactory.buildTilesWithRenderPadding(locations, TILE_SIZE_METERS, PADDING_METERS) + tiles.mapParallel { tile -> + val bitmap = withContext(Dispatchers.Default) { + HeatMapBitmapFactory.generateTileGradientBitmapFastSeamless( + positionsAll = locations.map { HeatMapBitmapFactory.Position(it, PADDING_METERS.toFloat()) }, + coreTile = tile, + widthPxCore = 250, + renderPaddingMeters = PADDING_METERS, + debugBorderPx = 0, + ) + } + + withContext(Dispatchers.Main) { + val heatmapOverlay = GroundOverlay() + heatmapOverlay.setImage(bitmap) + heatmapOverlay.setPosition(tile.topLeft.toGeoPoint(), tile.bottomRight.toGeoPoint()) + mapUpdate.map.overlays.add(0, heatmapOverlay) + } + + mapUpdate.map.invalidate() + } + } else { + mapUpdate.map.overlays.removeAll { it is GroundOverlay } + 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, @@ -916,8 +1009,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 { @@ -943,40 +1036,10 @@ object DeviceDetailsScreen { mapUpdate.map.overlays.add(fastPointOverlay) mapUpdate.map.invalidate() } - - DeviceDetailsViewModel.PointsStyle.HEAT_MAP -> { - val heatmapOverlay = GroundOverlay() - val tile = HeatMapBitmapFactory.Tile() - heatmapOverlay.setImage() - } } + } - - 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 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 1aee6b81..3777338d 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 @@ -72,6 +72,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) @@ -438,8 +442,6 @@ 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), - - HEAT_MAP(R.string.device_history_pint_style_heatmap), } sealed interface MapCameraState { 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 index 3d7ac596..34dba272 100644 --- a/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -2,50 +2,33 @@ package f.cking.software.utils.graphic import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint -import android.graphics.PointF -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode -import android.graphics.RadialGradient -import android.graphics.Shader +import android.graphics.Rect import android.location.Location -import androidx.core.graphics.component1 -import androidx.core.graphics.component2 -import androidx.core.graphics.createBitmap import kotlin.math.abs +import kotlin.math.ceil import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.round +import kotlin.math.roundToInt +import kotlin.math.sqrt object HeatMapBitmapFactory { data class Position(val location: Location, val radiusMeters: Float) data class Tile(val topLeft: Location, val bottomRight: Location) - /** - * Generates a heatmap-like bitmap for the given tile. - * - * Colors: - * - Red at center - * - Green at radius edge - * - Transparent outside - * - * @param positions points with radius in meters - * @param tile geographic bounds (topLeft has bigger lat, smaller lng) - * @param widthPx desired bitmap width in pixels - * @param additiveBlend if true, overlaps brighten via ADD; else normal SRC_OVER alpha - */ - suspend fun generateTileGradientBitmap( + private suspend fun generateTileGradientBitmapFastNoNormalize( positions: List, tile: Tile, widthPx: Int, - additiveBlend: Boolean = false + downsample: Int, + blurSigmaPx: Float, + debugBorderPx: Int = 0 ): Bitmap { - require(widthPx > 0) - - // --- Simple equirectangular meters-per-degree approximation --- fun metersPerDegLat(): Double = 111_320.0 fun metersPerDegLng(atLatDeg: Double): Double = 111_320.0 * cos(Math.toRadians(atLatDeg)) @@ -54,86 +37,360 @@ object HeatMapBitmapFactory { 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 tileCenterLat = (latTop + latBottom) / 2.0 - val mPerDegLat = metersPerDegLat() - val mPerDegLng = metersPerDegLng(tileCenterLat) + val ds = downsample.coerceAtLeast(1) + val wL = max(1, widthPx / ds) + val hL = max(1, heightPx / ds) + val sizeL = wL * hL - val tileWidthMeters = abs(lngRight - lngLeft) * mPerDegLng - val tileHeightMeters = abs(latTop - latBottom) * mPerDegLat + val pxPerMeterX_L = wL / tileWidthMeters + val pxPerMeterY_L = hL / tileHeightMeters + val pxPerMeter_L = min(pxPerMeterX_L, pxPerMeterY_L) - if (tileWidthMeters <= 0.0 || tileHeightMeters <= 0.0) { - return createBitmap(widthPx, widthPx) + 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 heightPx = max(1, round(widthPx * (tileHeightMeters / tileWidthMeters)).toInt()) + val intensityL = FloatArray(sizeL) - val bitmap = createBitmap(widthPx, heightPx) - val canvas = Canvas(bitmap) - canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + fun smoothstep(t: Float): Float { + val x = t.coerceIn(0f, 1f) + return x * x * (3f - 2f * x) + } - val pxPerMeterX = widthPx / tileWidthMeters - val pxPerMeterY = heightPx / tileHeightMeters - val pxPerMeter = min(pxPerMeterX, pxPerMeterY) // keep circles circular + 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 + } + } - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - isDither = true + // 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 (additiveBlend) { - paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.ADD) + + if (intensityL.maxOrNull() ?: 0f <= 0f) { + return Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) } - fun latLngToPixel(lat: Double, lng: Double): PointF { - val xNorm = (lng - lngLeft) / (lngRight - lngLeft) - val yNorm = (latTop - lat) / (latTop - latBottom) // top->0, bottom->1 - return PointF( - (xNorm * widthPx).toFloat(), - (yNorm * heightPx).toFloat() - ) + 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 } - val red = Color.argb(255, 255, 0, 0) - val green = Color.argb(255, 0, 255, 0) - val transparent = Color.TRANSPARENT + // visibility boost so lone blobs aren't too faint + val alphaScale = 1.6f - for (pos in positions) { - val loc = pos.location - val (px, py) = latLngToPixel(loc.latitude, loc.longitude) - val radiusPx = max(1f, pos.radiusMeters * pxPerMeter.toFloat()) - - // Cheap cull if fully outside - if (px + radiusPx < 0 || px - radiusPx > widthPx || - py + radiusPx < 0 || py - radiusPx > heightPx - ) continue - - paint.shader = RadialGradient( - px, py, radiusPx, - intArrayOf(red, green, transparent), - floatArrayOf(0f, 0.98f, 1f), - Shader.TileMode.CLAMP - ) - canvas.drawCircle(px, py, radiusPx, paint) + 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 + } } - paint.shader = null - paint.xfermode = null + val immutable = Bitmap.createBitmap(out, widthPx, heightPx, Bitmap.Config.ARGB_8888) + val bmp = immutable.copy(Bitmap.Config.ARGB_8888, true) + drawDebugBorder(bmp, debugBorderPx) + return bmp + } + + /** + * 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 - return bitmap + 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 + } + } + + suspend fun generateTileGradientBitmapFastSeamless( + positionsAll: List, + coreTile: Tile, + widthPxCore: Int, + renderPaddingMeters: Double, + downsample: Int = 5, + blurSigmaPxLow: Float = 4f, + debugBorderPx: Int = 0 + ): Bitmap { + require(widthPxCore > 0) + + // 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, + debugBorderPx = 0 + ) + + // 4) Crop padded bitmap back to core area + val cropRect = computeCoreCropRectPx(coreTile, renderTile, bmpPadded.width, bmpPadded.height) + val bmpCore = Bitmap.createBitmap( + bmpPadded, + cropRect.left, + cropRect.top, + cropRect.width(), + cropRect.height() + ).copy(Bitmap.Config.ARGB_8888, true) + + // optional border for debug + drawDebugBorder(bmpCore, debugBorderPx) + + return bmpCore + } + + 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) + ) } /** - * Computes a minimal Tile that contains all locations plus a padding margin in meters. + * Build fixed NxN-meter tiles (non-overlapping grid), anchored at global top-left. + * Returns all non-empty core tiles PLUS any neighbor tiles required by paddingMeters + * so point radii near borders won't be clipped. * - * Uses local meters-per-degree approximations. Suitable for typical map region sizes. + * Neighbor tiles may be empty; they're included as render buffers. */ - fun computeBoundingTile( + fun buildTilesWithRenderPadding( points: List, - paddingMeters: Double - ): Tile { - require(points.isNotEmpty()) + tileSizeMeters: Double, + paddingMeters: Double = 0.0 + ): List { + if (points.isEmpty() || tileSizeMeters <= 0.0 || paddingMeters < 0.0) return emptyList() - // Extract numeric bounds + // 1) Global bounds to set origin and reference latitude var minLat = Double.POSITIVE_INFINITY var maxLat = Double.NEGATIVE_INFINITY var minLng = Double.POSITIVE_INFINITY @@ -148,29 +405,126 @@ object HeatMapBitmapFactory { if (lng > maxLng) maxLng = lng } - // Approximate meters per degree using center latitude - val centerLat = (minLat + maxLat) / 2.0 + val refLat = (minLat + maxLat) / 2.0 val mPerDegLat = 111_320.0 - val mPerDegLng = 111_320.0 * cos(Math.toRadians(centerLat)) + val mPerDegLng = 111_320.0 * cos(Math.toRadians(refLat)) - // Convert meter padding → degree padding - val padLat = paddingMeters / mPerDegLat - val padLng = paddingMeters / mPerDegLng + // Origin at global TOP-LEFT (NW) + val originLat = maxLat + val originLng = minLng - val paddedMinLat = minLat - padLat - val paddedMaxLat = maxLat + padLat - val paddedMinLng = minLng - padLng - val paddedMaxLng = maxLng + padLng + fun toMetersFromOrigin(lat: Double, lng: Double): Pair { + val x = (lng - originLng) * mPerDegLng // east + + val y = (originLat - lat) * mPerDegLat // south + + return x to y + } + + fun toLatLngFromOrigin(xMeters: Double, yMeters: Double): Pair { + val lat = originLat - (yMeters / mPerDegLat) + val lng = originLng + (xMeters / mPerDegLng) + return lat to lng + } + + data class IJ(val i: Int, val j: Int) + val tilesToRender = HashSet(points.size * 4) - fun loc(lat: Double, lng: Double): Location = - Location("bounding").apply { + val N = tileSizeMeters + val P = paddingMeters + + // Helper: how many tiles outward are needed if a point is d meters from an edge? + fun outwardSteps(distToEdge: Double): Int { + if (P <= distToEdge) return 0 + val extra = P - distToEdge + return ceil(extra / N).toInt().coerceAtLeast(1) + } + + // 2) For each point, add its core tile + required neighbor tiles + for (p in points) { + val (x, y) = toMetersFromOrigin(p.latitude, p.longitude) + + val i0 = floor(x / N).toInt() + val j0 = floor(y / N).toInt() + tilesToRender.add(IJ(i0, j0)) + + val lx = x - i0 * N // local x in [0, N) + val ly = y - j0 * N // local y in [0, N) + + val distLeft = lx + val distRight = N - lx + val distTop = ly // because y grows south from top-left origin + val distBottom = N - ly + + val leftSteps = outwardSteps(distLeft) + val rightSteps = outwardSteps(distRight) + val topSteps = outwardSteps(distTop) + val bottomSteps = outwardSteps(distBottom) + + // Add 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)) + + // Add corner neighbors (cross-product of required steps) + 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)) + } + } + + // 3) Convert tile indices -> geographic Tiles + fun makeLocation(lat: Double, lng: Double) = + Location("tile").apply { latitude = lat longitude = lng } - val topLeft = loc(paddedMaxLat, paddedMinLng) - val bottomRight = loc(paddedMinLat, paddedMaxLng) + 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 + + val (latTop, lngLeft) = toLatLngFromOrigin(x0, y0) + val (latBottom, lngRight) = toLatLngFromOrigin(x1, y1) + + 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() + } - return Tile(topLeft, bottomRight) + 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 f1705d4d..1ba97b43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,7 +251,7 @@ History style Markers Path - Heat map + Range heatmap %s ago lifetime: %1$s | last update: %2$s ago lifetime: %1$s From 5e546617648542b19e79bc836494a9bdf6333371 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Mon, 24 Nov 2025 00:57:46 -0800 Subject: [PATCH 3/7] Update tiles --- .../java/f/cking/software/ui/MainActivity.kt | 37 ++++- .../ui/devicedetails/DeviceDetailsScreen.kt | 154 +++++++++++++++--- .../devicedetails/DeviceDetailsViewModel.kt | 1 + .../f/cking/software/utils/ComposeUtils.kt | 6 + .../main/java/f/cking/software/utils/ext.kt | 20 ++- .../utils/graphic/HeatMapBitmapFactory.kt | 72 +++++++- app/src/main/res/values/strings.xml | 2 + 7 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/f/cking/software/utils/ComposeUtils.kt 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 08e7fbeb..312b5618 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 57a33588..d374e82d 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,6 +77,7 @@ 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 @@ -83,14 +87,19 @@ 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.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 @@ -120,10 +129,13 @@ import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlayOptions import org.osmdroid.views.overlay.simplefastpoint.SimplePointTheme import timber.log.Timber import java.time.format.FormatStyle +import java.util.concurrent.ConcurrentHashMap @OptIn(ExperimentalMaterial3Api::class) object DeviceDetailsScreen { + private const val TAG = "DeviceDetailsScreen" + @Composable fun Screen( address: String, @@ -196,7 +208,7 @@ object DeviceDetailsScreen { if (deviceData == null) { Progress(modifier) } else { - DeviceDetails(modifier = modifier, viewModel = viewModel, deviceData = deviceData) + DeviceDetails(modifier, viewModel, deviceData) } } @@ -214,11 +226,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 } @@ -229,10 +245,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(500.dp), + .animateContentSize() + .height(mapBlockSizePx.dp), deviceData = deviceData, viewModel = viewModel, isMoving = isMoving, @@ -725,6 +745,7 @@ object DeviceDetailsScreen { Box( modifier = Modifier .fillMaxWidth() + .animateContentSize() .weight(1f) ) { Map( @@ -803,6 +824,22 @@ object DeviceDetailsScreen { 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, + ) + } } } @@ -899,7 +936,29 @@ object DeviceDetailsScreen { val mapColorScheme = remember { MapColorScheme(colorScheme.scrim.copy(alpha = 0.6f), Color.Red) } if (mapView != null) { - val mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView!!) + val mapView = mapView!! + val mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView) + + fun getViewport(): Tile { + return Tile(topLeft = mapView.projection.topLeft().toLocation(), mapView.projection.bottomRight().toLocation()) + } + + var viewport: Tile by remember { mutableStateOf(getViewport()) } + mapView.addMapListener(object : MapListener { + private fun updateViewport() { + viewport = getViewport() + } + + override fun onScroll(event: ScrollEvent): Boolean { + updateViewport() + return true + } + + override fun onZoom(event: ZoomEvent): Boolean { + updateViewport() + return true + } + }) LaunchedEffect(mapView, viewModel.pointsState, viewModel.pointsStyle) { refreshMap(mapUpdate, batchProcessor, mapColorScheme, viewModel.pointsStyle) } @@ -908,8 +967,9 @@ object DeviceDetailsScreen { updateMapCamera(mapUpdate) } - LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap) { - renderHeatmap(mapUpdate, viewModel) + val tilesState = rememberTilesState() + LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap, viewport) { + renderHeatmap(mapUpdate, viewport, viewModel, tilesState) } } } @@ -937,29 +997,79 @@ object DeviceDetailsScreen { private const val PADDING_METERS = 50.0 private const val TILE_SIZE_METERS = 300.0 - private suspend fun renderHeatmap(mapUpdate: MapUpdate, viewModel: DeviceDetailsViewModel) { + @Composable + private fun rememberTilesState() = remember { TilesState() } + private class TilesState { + var tiles = ConcurrentHashMap() + } + + 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) { - val locations = mapUpdate.points.map { it.toLocation() } - val tiles = HeatMapBitmapFactory.buildTilesWithRenderPadding(locations, TILE_SIZE_METERS, PADDING_METERS) - tiles.mapParallel { tile -> - val bitmap = withContext(Dispatchers.Default) { - HeatMapBitmapFactory.generateTileGradientBitmapFastSeamless( - positionsAll = locations.map { HeatMapBitmapFactory.Position(it, PADDING_METERS.toFloat()) }, + withContext(Dispatchers.Default) { + val locations = mapUpdate.points.map { it.toLocation() }.filter { viewport.contains(it, 0.0) } + Timber.tag(TAG).d("Heatmap points: ${locations.size}") + val tiles = HeatMapBitmapFactory.buildTilesWithRenderPadding(locations, TILE_SIZE_METERS, PADDING_METERS) + Timber.tag(TAG).d("Heatmap tiles: ${tiles.size}") + + 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) + } + + tiles.mapParallel { tile -> + 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") + if (!mapUpdate.map.overlays.contains(existedTile.overlay)) { + mapUpdate.map.overlays.add(0, existedTile.overlay) + } + return@mapParallel + } 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 = 250, + widthPxCore = 300, renderPaddingMeters = PADDING_METERS, - debugBorderPx = 0, + debugBorderPx = 1, ) - } - withContext(Dispatchers.Main) { val heatmapOverlay = GroundOverlay() heatmapOverlay.setImage(bitmap) heatmapOverlay.setPosition(tile.topLeft.toGeoPoint(), tile.bottomRight.toGeoPoint()) - mapUpdate.map.overlays.add(0, heatmapOverlay) + withContext(Dispatchers.Main) { + mapUpdate.map.overlays.add(0, heatmapOverlay) + } + tilesState.tiles[tile] = TilesData(tile, heatmapOverlay, positionsForTile) } - mapUpdate.map.invalidate() + withContext(Dispatchers.Main) { + mapUpdate.map.invalidate() + } + + Timber.tag(TAG).d("All tiles rendered") } } else { mapUpdate.map.overlays.removeAll { it is GroundOverlay } @@ -1036,6 +1146,12 @@ object DeviceDetailsScreen { mapUpdate.map.overlays.add(fastPointOverlay) mapUpdate.map.invalidate() } + + DeviceDetailsViewModel.PointsStyle.HIDE_MARKERS -> { + batchProcessor.cancel() + mapUpdate.map.overlays.clearPoints() + mapUpdate.map.invalidate() + } } } 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 3777338d..e2b072ec 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 @@ -442,6 +442,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 { 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 00000000..378df48d --- /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 c24c67a9..0e12f7a4 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,8 @@ 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.views.Projection import timber.log.Timber import java.security.MessageDigest import java.time.Instant @@ -227,4 +230,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 fromPixels(screenRect.top, screenRect.left, null, true) +} + +fun Projection.bottomRight(): IGeoPoint { + return fromPixels(screenRect.bottom, screenRect.right, null, true) +} \ 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 index 34dba272..d073a549 100644 --- a/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -5,6 +5,8 @@ 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.ceil import kotlin.math.cos @@ -19,7 +21,49 @@ import kotlin.math.sqrt object HeatMapBitmapFactory { data class Position(val location: Location, val radiusMeters: Float) - data class Tile(val topLeft: Location, val bottomRight: Location) + data class Tile(val topLeft: Location, val bottomRight: Location) { + + val tileId = "${topLeft.latitude},${topLeft.longitude}" + + 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 suspend fun generateTileGradientBitmapFastNoNormalize( positions: List, @@ -57,6 +101,7 @@ object HeatMapBitmapFactory { 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) @@ -118,8 +163,8 @@ object HeatMapBitmapFactory { } } - if (intensityL.maxOrNull() ?: 0f <= 0f) { - return Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) + if ((intensityL.maxOrNull() ?: 0f) <= 0f) { + return createBitmap(widthPx, heightPx) } val blurredL = gaussianBlurSeparable(intensityL, wL, hL, blurSigmaPx) @@ -298,6 +343,18 @@ object HeatMapBitmapFactory { } } + 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) + suspend fun generateTileGradientBitmapFastSeamless( positionsAll: List, coreTile: Tile, @@ -309,6 +366,11 @@ object HeatMapBitmapFactory { ): 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) @@ -339,6 +401,8 @@ object HeatMapBitmapFactory { // optional border for debug drawDebugBorder(bmpCore, debugBorderPx) + bitmapCache.put(key, bmpCore) + return bmpCore } @@ -358,6 +422,7 @@ object HeatMapBitmapFactory { 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() @@ -426,6 +491,7 @@ object HeatMapBitmapFactory { } data class IJ(val i: Int, val j: Int) + val tilesToRender = HashSet(points.size * 4) val N = tileSizeMeters diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ba97b43..9969115a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,6 +251,7 @@ History style Markers Path + Hide markers Range heatmap %s ago lifetime: %1$s | last update: %2$s ago @@ -289,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. From 7e77ee330df3076a39d703b2124c6dac2e0d7be1 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Mon, 24 Nov 2025 02:05:54 -0800 Subject: [PATCH 4/7] Render tiles inside viewport --- .../ui/devicedetails/DeviceDetailsScreen.kt | 39 ++-- .../devicedetails/DeviceDetailsViewModel.kt | 5 + .../main/java/f/cking/software/utils/ext.kt | 5 +- .../utils/graphic/HeatMapBitmapFactory.kt | 166 +++++++++++++++++- 4 files changed, 197 insertions(+), 18 deletions(-) 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 d374e82d..947fbb4a 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 @@ -112,6 +112,7 @@ import f.cking.software.utils.graphic.infoDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive 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 @@ -1001,6 +1002,7 @@ object DeviceDetailsScreen { private fun rememberTilesState() = remember { TilesState() } private class TilesState { var tiles = ConcurrentHashMap() + var lastLocationsState: List = emptyList() } private data class TilesData( @@ -1010,21 +1012,27 @@ object DeviceDetailsScreen { ) 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() }.filter { viewport.contains(it, 0.0) } + val locations = mapUpdate.points.map { it.toLocation() } Timber.tag(TAG).d("Heatmap points: ${locations.size}") - val tiles = HeatMapBitmapFactory.buildTilesWithRenderPadding(locations, TILE_SIZE_METERS, PADDING_METERS) + val tiles = HeatMapBitmapFactory.buildTilesWithRenderPaddingStable(locations, TILE_SIZE_METERS, PADDING_METERS) + .filter { viewport.intersects(it) } Timber.tag(TAG).d("Heatmap tiles: ${tiles.size}") - val removedTiles = tilesState.tiles.values.filter { !tiles.contains(it.tile) } + if (tilesState.lastLocationsState != mapUpdate.points) { - removedTiles.forEach { tileData -> - Timber.tag(TAG).d("Tile exists but should be removed") - tilesState.tiles.remove(tileData.tile) - mapUpdate.map.overlays.remove(tileData.overlay) + 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 + tiles.mapParallel { tile -> val positionsForTile = locations .mapNotNull { @@ -1036,8 +1044,11 @@ object DeviceDetailsScreen { if (existedTile != null && positionsForTile == existedTile.locations) { // tile is already added and didn't change Timber.tag(TAG).d("Tile already rendered") - if (!mapUpdate.map.overlays.contains(existedTile.overlay)) { - mapUpdate.map.overlays.add(0, existedTile.overlay) + withContext(Dispatchers.IO) { + if (!mapUpdate.map.overlays.contains(existedTile.overlay)) { + mapUpdate.map.overlays.add(0, existedTile.overlay) + mapUpdate.map.invalidate() + } } return@mapParallel } else if (existedTile != null) { @@ -1047,13 +1058,14 @@ object DeviceDetailsScreen { tilesState.tiles.remove(tile) } + yield() Timber.tag(TAG).d("Rendering tile with ${positionsForTile.size} points") val bitmap = HeatMapBitmapFactory.generateTileGradientBitmapFastSeamless( positionsAll = positionsForTile, coreTile = tile, widthPxCore = 300, renderPaddingMeters = PADDING_METERS, - debugBorderPx = 1, + debugBorderPx = 0, ) val heatmapOverlay = GroundOverlay() @@ -1061,20 +1073,17 @@ object DeviceDetailsScreen { 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) } - withContext(Dispatchers.Main) { - mapUpdate.map.invalidate() - } - Timber.tag(TAG).d("All tiles rendered") } } else { mapUpdate.map.overlays.removeAll { it is GroundOverlay } - mapUpdate.map.invalidate() } + mapUpdate.map.invalidate() } private fun updateMapCamera(mapUpdate: MapUpdate) { 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 e2b072ec..d1574e87 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 @@ -365,6 +365,10 @@ class DeviceDetailsViewModel( pointsStyle = PointsStyle.PATH } + if (fetched.size > MAX_POINTS_FOR_HEATMAP) { + useHeatmap = false + } + pointsState = fetched updateCameraPosition(pointsState, currentLocation) } @@ -466,6 +470,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/ext.kt b/app/src/main/java/f/cking/software/utils/ext.kt index 0e12f7a4..2bafa369 100644 --- a/app/src/main/java/f/cking/software/utils/ext.kt +++ b/app/src/main/java/f/cking/software/utils/ext.kt @@ -24,6 +24,7 @@ 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 @@ -240,9 +241,9 @@ fun IGeoPoint.toLocation(): Location { } fun Projection.topLeft(): IGeoPoint { - return fromPixels(screenRect.top, screenRect.left, null, true) + return GeoPoint(boundingBox.latNorth, boundingBox.lonWest) } fun Projection.bottomRight(): IGeoPoint { - return fromPixels(screenRect.bottom, screenRect.right, null, true) + 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 index d073a549..d1b96805 100644 --- a/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -8,15 +8,18 @@ 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 object HeatMapBitmapFactory { @@ -25,6 +28,59 @@ object HeatMapBitmapFactory { val tileId = "${topLeft.latitude},${topLeft.longitude}" + + /** + * 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) @@ -448,7 +504,7 @@ object HeatMapBitmapFactory { * * Neighbor tiles may be empty; they're included as render buffers. */ - fun buildTilesWithRenderPadding( + fun buildTilesWithRenderPaddingOld( points: List, tileSizeMeters: Double, paddingMeters: Double = 0.0 @@ -570,6 +626,114 @@ object HeatMapBitmapFactory { return result } + + 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, From 1699c1f4d2989766c3e06e10dec7dee64424ec5a Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Mon, 24 Nov 2025 12:27:36 -0800 Subject: [PATCH 5/7] Render tiles inside viewport --- .../ui/devicedetails/DeviceDetailsScreen.kt | 66 ++++++++++++------- .../devicedetails/DeviceDetailsViewModel.kt | 1 + 2 files changed, 43 insertions(+), 24 deletions(-) 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 947fbb4a..f3483f71 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 @@ -110,7 +110,9 @@ 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 @@ -796,7 +798,7 @@ object DeviceDetailsScreen { } } - if (viewModel.markersInLoadingState) { + if (viewModel.markersInLoadingState || viewModel.loadingHeatmap) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -892,6 +894,25 @@ 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() + Timber.tag(TAG).d("Update viewport") + } + + fun commitViewPort() { + committedViewport = pendingViewport + Timber.tag(TAG).d("Commit viewport") + } + MapView( modifier = modifier.pointerInteropFilter { event -> if (mapView != null) { @@ -921,16 +942,19 @@ 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 } ) @@ -938,28 +962,15 @@ object DeviceDetailsScreen { if (mapView != null) { val mapView = mapView!! + val mapUpdate = MapUpdate(viewModel.pointsState, viewModel.cameraState, mapView) - fun getViewport(): Tile { - return Tile(topLeft = mapView.projection.topLeft().toLocation(), mapView.projection.bottomRight().toLocation()) + LaunchedEffect(pendingViewport) { + delay(50L) + yield() + commitViewPort() } - var viewport: Tile by remember { mutableStateOf(getViewport()) } - mapView.addMapListener(object : MapListener { - private fun updateViewport() { - viewport = getViewport() - } - - override fun onScroll(event: ScrollEvent): Boolean { - updateViewport() - return true - } - - override fun onZoom(event: ZoomEvent): Boolean { - updateViewport() - return true - } - }) LaunchedEffect(mapView, viewModel.pointsState, viewModel.pointsStyle) { refreshMap(mapUpdate, batchProcessor, mapColorScheme, viewModel.pointsStyle) } @@ -969,8 +980,9 @@ object DeviceDetailsScreen { } val tilesState = rememberTilesState() - LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap, viewport) { - renderHeatmap(mapUpdate, viewport, viewModel, tilesState) + LaunchedEffect(mapView, viewModel.pointsState, viewModel.useHeatmap, committedViewport) { + val committedViewport = committedViewport ?: return@LaunchedEffect + renderHeatmap(mapUpdate, committedViewport, viewModel, tilesState) } } } @@ -1033,6 +1045,11 @@ object DeviceDetailsScreen { tilesState.lastLocationsState = mapUpdate.points + val loadingJob = launch { + delay(30) + yield() + viewModel.loadingHeatmap = true + } tiles.mapParallel { tile -> val positionsForTile = locations .mapNotNull { @@ -1057,8 +1074,6 @@ object DeviceDetailsScreen { mapUpdate.map.overlays.remove(existedTile.overlay) tilesState.tiles.remove(tile) } - - yield() Timber.tag(TAG).d("Rendering tile with ${positionsForTile.size} points") val bitmap = HeatMapBitmapFactory.generateTileGradientBitmapFastSeamless( positionsAll = positionsForTile, @@ -1068,6 +1083,7 @@ object DeviceDetailsScreen { debugBorderPx = 0, ) + yield() val heatmapOverlay = GroundOverlay() heatmapOverlay.setImage(bitmap) heatmapOverlay.setPosition(tile.topLeft.toGeoPoint(), tile.bottomRight.toGeoPoint()) @@ -1079,6 +1095,8 @@ object DeviceDetailsScreen { } Timber.tag(TAG).d("All tiles rendered") + loadingJob.cancel() + viewModel.loadingHeatmap = false } } else { mapUpdate.map.overlays.removeAll { it is GroundOverlay } 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 d1574e87..45207b1b 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()) From a84144f025004287fa1fddf558d23ac0d112d551 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Mon, 24 Nov 2025 13:40:43 -0800 Subject: [PATCH 6/7] Render tiles inside viewport --- .../ui/devicedetails/DeviceDetailsScreen.kt | 113 +++++---- .../utils/graphic/HeatMapBitmapFactory.kt | 217 ++++++------------ 2 files changed, 133 insertions(+), 197 deletions(-) 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 f3483f71..8c2f1f53 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 @@ -88,6 +88,7 @@ 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 @@ -132,7 +133,6 @@ import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlayOptions import org.osmdroid.views.overlay.simplefastpoint.SimplePointTheme import timber.log.Timber import java.time.format.FormatStyle -import java.util.concurrent.ConcurrentHashMap @OptIn(ExperimentalMaterial3Api::class) object DeviceDetailsScreen { @@ -905,12 +905,10 @@ object DeviceDetailsScreen { fun updateViewport() { pendingViewport = getViewport() - Timber.tag(TAG).d("Update viewport") } fun commitViewPort() { committedViewport = pendingViewport - Timber.tag(TAG).d("Commit viewport") } MapView( @@ -1013,7 +1011,7 @@ object DeviceDetailsScreen { @Composable private fun rememberTilesState() = remember { TilesState() } private class TilesState { - var tiles = ConcurrentHashMap() + var tiles = HashMap() var lastLocationsState: List = emptyList() } @@ -1029,12 +1027,26 @@ object DeviceDetailsScreen { 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) - .filter { viewport.intersects(it) } - Timber.tag(TAG).d("Heatmap tiles: ${tiles.size}") + .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() - if (tilesState.lastLocationsState != mapUpdate.points) { + 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") @@ -1045,62 +1057,69 @@ object DeviceDetailsScreen { tilesState.lastLocationsState = mapUpdate.points - val loadingJob = launch { + val loadingJob = launch(Dispatchers.Main) { delay(30) yield() viewModel.loadingHeatmap = true } - tiles.mapParallel { tile -> - 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) + + 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) } - return@mapParallel - } 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.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() - viewModel.loadingHeatmap = false } } else { mapUpdate.map.overlays.removeAll { it is GroundOverlay } } + viewModel.loadingHeatmap = false mapUpdate.map.invalidate() } 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 index d1b96805..84f4e638 100644 --- a/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt +++ b/app/src/main/java/f/cking/software/utils/graphic/HeatMapBitmapFactory.kt @@ -21,14 +21,14 @@ 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) { - val tileId = "${topLeft.latitude},${topLeft.longitude}" - - /** * True if this tile intersects (collides) with [other]. * @@ -121,13 +121,12 @@ object HeatMapBitmapFactory { } } - private suspend fun generateTileGradientBitmapFastNoNormalize( + private fun generateTileGradientBitmapFastNoNormalize( positions: List, tile: Tile, widthPx: Int, downsample: Int, blurSigmaPx: Float, - debugBorderPx: Int = 0 ): Bitmap { fun metersPerDegLat(): Double = 111_320.0 fun metersPerDegLng(atLatDeg: Double): Double = @@ -267,10 +266,7 @@ object HeatMapBitmapFactory { } } - val immutable = Bitmap.createBitmap(out, widthPx, heightPx, Bitmap.Config.ARGB_8888) - val bmp = immutable.copy(Bitmap.Config.ARGB_8888, true) - drawDebugBorder(bmp, debugBorderPx) - return bmp + return Bitmap.createBitmap(out, widthPx, heightPx, Bitmap.Config.ARGB_8888) } /** @@ -411,12 +407,59 @@ object HeatMapBitmapFactory { private val bitmapCache = LruCache(maxSize = 100) - suspend fun generateTileGradientBitmapFastSeamless( + 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 = 5, + downsample: Int = 4, blurSigmaPxLow: Float = 4f, debugBorderPx: Int = 0 ): Bitmap { @@ -441,25 +484,28 @@ object HeatMapBitmapFactory { widthPx = computeWidthForRenderTile(coreTile, renderTile, widthPxCore), downsample = downsample, blurSigmaPx = blurSigmaPxLow, - debugBorderPx = 0 ) // 4) Crop padded bitmap back to core area val cropRect = computeCoreCropRectPx(coreTile, renderTile, bmpPadded.width, bmpPadded.height) - val bmpCore = Bitmap.createBitmap( + + val immutableBitmap = Bitmap.createBitmap( bmpPadded, cropRect.left, cropRect.top, cropRect.width(), cropRect.height() - ).copy(Bitmap.Config.ARGB_8888, true) - - // optional border for debug - drawDebugBorder(bmpCore, debugBorderPx) - - bitmapCache.put(key, bmpCore) + ) - return bmpCore + 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( @@ -497,136 +543,6 @@ object HeatMapBitmapFactory { ) } - /** - * Build fixed NxN-meter tiles (non-overlapping grid), anchored at global top-left. - * Returns all non-empty core tiles PLUS any neighbor tiles required by paddingMeters - * so point radii near borders won't be clipped. - * - * Neighbor tiles may be empty; they're included as render buffers. - */ - fun buildTilesWithRenderPaddingOld( - points: List, - tileSizeMeters: Double, - paddingMeters: Double = 0.0 - ): List { - if (points.isEmpty() || tileSizeMeters <= 0.0 || paddingMeters < 0.0) return emptyList() - - // 1) Global bounds to set origin and reference latitude - var minLat = Double.POSITIVE_INFINITY - var maxLat = Double.NEGATIVE_INFINITY - var minLng = Double.POSITIVE_INFINITY - var maxLng = Double.NEGATIVE_INFINITY - - for (p in points) { - val lat = p.latitude - val lng = p.longitude - if (lat < minLat) minLat = lat - if (lat > maxLat) maxLat = lat - if (lng < minLng) minLng = lng - if (lng > maxLng) maxLng = lng - } - - val refLat = (minLat + maxLat) / 2.0 - val mPerDegLat = 111_320.0 - val mPerDegLng = 111_320.0 * cos(Math.toRadians(refLat)) - - // Origin at global TOP-LEFT (NW) - val originLat = maxLat - val originLng = minLng - - fun toMetersFromOrigin(lat: Double, lng: Double): Pair { - val x = (lng - originLng) * mPerDegLng // east + - val y = (originLat - lat) * mPerDegLat // south + - return x to y - } - - fun toLatLngFromOrigin(xMeters: Double, yMeters: Double): Pair { - val lat = originLat - (yMeters / mPerDegLat) - val lng = originLng + (xMeters / mPerDegLng) - return lat to lng - } - - data class IJ(val i: Int, val j: Int) - - val tilesToRender = HashSet(points.size * 4) - - val N = tileSizeMeters - val P = paddingMeters - - // Helper: how many tiles outward are needed if a point is d meters from an edge? - fun outwardSteps(distToEdge: Double): Int { - if (P <= distToEdge) return 0 - val extra = P - distToEdge - return ceil(extra / N).toInt().coerceAtLeast(1) - } - - // 2) For each point, add its core tile + required neighbor tiles - for (p in points) { - val (x, y) = toMetersFromOrigin(p.latitude, p.longitude) - - val i0 = floor(x / N).toInt() - val j0 = floor(y / N).toInt() - tilesToRender.add(IJ(i0, j0)) - - val lx = x - i0 * N // local x in [0, N) - val ly = y - j0 * N // local y in [0, N) - - val distLeft = lx - val distRight = N - lx - val distTop = ly // because y grows south from top-left origin - val distBottom = N - ly - - val leftSteps = outwardSteps(distLeft) - val rightSteps = outwardSteps(distRight) - val topSteps = outwardSteps(distTop) - val bottomSteps = outwardSteps(distBottom) - - // Add 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)) - - // Add corner neighbors (cross-product of required steps) - 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)) - } - } - - // 3) Convert tile indices -> geographic Tiles - 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 - - val (latTop, lngLeft) = toLatLngFromOrigin(x0, y0) - val (latBottom, lngRight) = toLatLngFromOrigin(x1, y1) - - result.add( - Tile( - topLeft = makeLocation(latTop, lngLeft), - bottomRight = makeLocation(latBottom, lngRight) - ) - ) - } - - return result - } - - fun buildTilesWithRenderPaddingStable( points: List, tileSizeMeters: Double, @@ -660,6 +576,7 @@ object HeatMapBitmapFactory { } data class IJ(val i: Int, val j: Int) + val tilesToRender = HashSet(points.size * 4) fun outwardSteps(distToEdge: Double): Int { From 6009c8e00a0e4ea1d6286cb3901c6b6c607832e9 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Mon, 24 Nov 2025 13:44:29 -0800 Subject: [PATCH 7/7] Update app version --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd5a642d..562ce30b 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"