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:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user