feat(map): add OSMDroid heatmap with legacy dust tiers

Port legacy Google Maps heatmap behavior: PM2.5-style buckets, tier colors, halos under markers. Draw halos in Overlay.draw with live projection so heat tracks during pan (no debounced bitmap). Radioactivity uses single green tier to match marker styling.

Made-with: Cursor
This commit is contained in:
2026-03-24 00:21:00 +01:00
parent fc034ad520
commit 35d23110d7
3 changed files with 127 additions and 0 deletions

View File

@@ -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<GeoPoint>
)
/**
* 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<HeatmapTierBatch>
) : 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<MapMarker>): MapHeatmapOverlay? {
val byTier = LinkedHashMap<Int, MutableList<GeoPoint>>()
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<HeatmapTierBatch>()
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<MapHeatmapOverlay>().forEach { it.recycleBitmap() }
overlays.removeAll { it is MapHeatmapOverlay }
}

View File

@@ -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

View File

@@ -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,