diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt new file mode 100644 index 0000000..45cdce9 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt @@ -0,0 +1,114 @@ +package org.db3.airmq.features.map + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Point +import android.graphics.RadialGradient +import android.graphics.Shader +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import org.db3.airmq.R +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Overlay + +/** + * OSMDroid overlay that draws heat halos under markers, projected on every frame so pan/zoom + * stays aligned with the map (unlike a cached screen bitmap that only updates after debounce). + * Mirrors legacy Google Maps heatmap: PM2.5-style dust tiers, ~40dp radius, ~0.4 opacity. + */ +private const val HeatmapRadiusDp = 40f +private const val HeatmapOpacity = 0.4f + +private val heatmapTierColorOrder = intArrayOf( + R.color.sensorGreen, + R.color.sensorYellow, + R.color.sensorOrange, + R.color.sensorRed, + R.color.sensorPink, + R.color.sensorPurple +) + +private class HeatmapTierBatch( + val shader: RadialGradient, + val points: List +) + +/** + * One [RadialGradient] per tier (centered at origin); [Matrix] translates it each draw for low GC during pan. + */ +class MapHeatmapOverlay private constructor( + private val radiusPx: Float, + private val batches: List +) : Overlay() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val shaderMatrix = Matrix() + private val scratch = Point() + + override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { + if (shadow) return + val projection = mapView.projection + for (batch in batches) { + paint.shader = batch.shader + for (geo in batch.points) { + projection.toPixels(geo, scratch) + val cx = scratch.x.toFloat() + val cy = scratch.y.toFloat() + shaderMatrix.reset() + shaderMatrix.setTranslate(cx, cy) + batch.shader.setLocalMatrix(shaderMatrix) + canvas.drawCircle(cx, cy, radiusPx, paint) + } + } + paint.shader = null + } + + /** Kept for [removeHeatmapOverlaysRecycle]; no bitmap to recycle anymore. */ + fun recycleBitmap() {} + + companion object { + fun create(mapView: MapView, items: List): MapHeatmapOverlay? { + val byTier = LinkedHashMap>() + for (res in heatmapTierColorOrder) { + byTier[res] = ArrayList() + } + for (item in items) { + val colorRes = MapMarkerStyle.heatmapTierColorRes(item) ?: continue + byTier.getOrPut(colorRes) { ArrayList() } + .add(GeoPoint(item.latitude, item.longitude)) + } + + val density = mapView.context.resources.displayMetrics.density + val radiusPx = HeatmapRadiusDp * density + val centerAlpha = (255 * HeatmapOpacity).toInt().coerceIn(0, 255) + val ctx = mapView.context + + val batches = ArrayList() + for (colorRes in heatmapTierColorOrder) { + val points = byTier[colorRes] ?: continue + if (points.isEmpty()) continue + val rgb = ContextCompat.getColor(ctx, colorRes) + val centerColor = ColorUtils.setAlphaComponent(rgb, centerAlpha) + val shader = RadialGradient( + 0f, + 0f, + radiusPx, + centerColor, + Color.TRANSPARENT, + Shader.TileMode.CLAMP + ) + batches.add(HeatmapTierBatch(shader, points)) + } + if (batches.isEmpty()) return null + return MapHeatmapOverlay(radiusPx, batches) + } + } +} + +internal fun MapView.removeHeatmapOverlaysRecycle() { + overlays.filterIsInstance().forEach { it.recycleBitmap() } + overlays.removeAll { it is MapHeatmapOverlay } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt index 6faf33e..731fff1 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt @@ -16,6 +16,15 @@ object MapMarkerStyle { } } + /** + * Color tier for heatmap halos; null when there is no reading (excluded from heatmap). + */ + @ColorRes + fun heatmapTierColorRes(marker: MapMarker): Int? { + if (marker.value == null) return null + return valueColorRes(marker.value, marker.sensorType) + } + @ColorRes fun valueColorRes(value: Double?, sensorType: SensorType): Int { if (value == null) return R.color.colorGrey diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 9ed9264..caf5aed 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -334,6 +334,7 @@ private fun rebuildAirMqMapOverlays( initialCameraDone: AtomicBoolean ) { if (items.isEmpty()) { + map.removeHeatmapOverlaysRecycle() map.overlays.removeAll { it is Marker } initialCameraDone.set(false) map.invalidate() @@ -363,8 +364,11 @@ private fun rebuildAirMqMapOverlays( } val ctx = map.context + map.removeHeatmapOverlaysRecycle() map.overlays.removeAll { it is Marker } + MapHeatmapOverlay.create(map, items)?.let { map.overlays.add(it) } + val displayItems = MapMarkerClustering.buildDisplayItems( items = items, projection = map.projection,