This document lists confirmed issues found in the current heatmap stack. Each item includes the impact, the code location, a short root-cause analysis, and a minimal fix suggestion with code context.
-
Impact: Visible seams/misaligned intensity along tile borders; the same event near a border can land on different pixels in neighboring tiles.
-
Where:
map/src/main/java/com/adsamcik/tracker/map/heatmap/HeatmapTile.kt -
Why: Pixel coordinates are computed using
roundToInt()from continuous tile coordinates. Rounding differs across tiles and causes off-by-one shifts. Seam-safe rasterization should use floor with a tiny epsilon at integer boundaries. -
Evidence:
// HeatmapTile.add val tx = MapFunctions.toTileX(location.longitude, tileCount) val ty = MapFunctions.toTileY(location.latitude, tileCount) val x = ((tx - data.x) * data.heatmapSize).roundToInt() + data.pad val y = ((ty - data.y) * data.heatmapSize).roundToInt() + data.pad
Compare with the test strategy in
HeatTileEngineSeamTestthat verifies identical shared edges. Using floor is the standard for consistent binning. -
Fix suggestion:
// Prefer floor with epsilon; avoids double-hit or drop on exact borders val fx = (tx - data.x) * data.heatmapSize val fy = (ty - data.y) * data.heatmapSize val eps = 1e-6 // heat pixels val x = kotlin.math.floor(fx - eps).toInt() + data.pad val y = kotlin.math.floor(fy - eps).toInt() + data.pad
-
Impact: Tone/normalization flickers across tiles; cross-tile “global” normalization is weakened, producing visible steps when panning.
-
Where:
HeatmapTile.toByteArray -
Why: Even when
saturationOverrideis provided (computed from a 3×3 neighborhood and smoothed over time), it gets coerced into[0.75×localP, 1.50×localP]. This defeats the purpose of a neighborhood-wide normalization target in sparse/dense transitions. -
Evidence:
var saturation = data.saturationOverride ?: localP val minSat = localP * 0.75f val maxSat = localP * 1.50f saturation = saturation.coerceIn(minSat, maxSat)
-
Fix suggestion: Only clamp when override is null (i.e., using localP). If override exists, respect it or use a gentler clamp (e.g., global EMA on override upstream, no per-tile clamp).
var saturation = data.saturationOverride ?: localP if (data.saturationOverride == null) { val minSat = localP * 0.75f val maxSat = localP * 1.50f saturation = saturation.coerceIn(minSat, maxSat) }
-
Impact: Subtle color/alpha banding and frame-to-frame “popping” as the saturation snaps to 2% bins of
maxHeat. -
Where:
HeatmapTile.toByteArray -
Why: Quantizing saturation to
step = max(1f, 0.02 × maxHeat)is coarse, especially whenmaxHeatis small. With smooth EMA already applied to neighborhood saturation, this extra quantization adds artifacts. -
Evidence:
if (data.config.maxHeat > 0f) { val step = (data.config.maxHeat * 0.02f).coerceAtLeast(1f) // 2% bins saturation = kotlin.math.ceil(saturation / step) * step }
-
Fix suggestion: Remove hard quantization or reduce to ~0.5–1% and apply only when override is null. Prefer time-smoothed override.
-
Impact: Blocky/plateau look and stronger tile seams (flat intensity within the stamp extent).
-
Where:
layers/impl/CellHeatmapLayer.kt– heat config -
Why: The weight merge uses
max(current, value)and ignores the per-pixel stamp value, so every pixel under the stamp gets the same weight regardless of distance. -
Evidence:
weightMergeFunction = { current: Float, _: Int, _: Float, value: Float -> max(value, current) }
-
Fix suggestion: Incorporate
stampValueso falloff is respected. For max aggregation, take max overstampValue * value.weightMergeFunction = { current, _: Int, stampValue, value -> max(current, stampValue * value) }
-
Impact: Visual “mode switch” between neighboring tiles; one tile looks smooth, the next looks crisp, causing a checkerboard feel in sparse data.
-
Where:
HeatmapTile.toByteArray -
Why: Blur is applied only when
coverage < 0.25f, with a hard cutoff. Tiles around 25% toggle smoothing on/off abruptly. -
Evidence:
val coverage = preCoverage val blurred = if (coverage < 0.25f) gaussianBlur(normalized, paddedSize, paddedSize, radius = 2) else normalized
-
Fix suggestion: Blend blur radius or strength smoothly with coverage (e.g., radius = lerp(0..2) from 0.15..0.35 coverage), not a hard switch.
-
Impact: Tiles with different time spans render at different apparent intensities for identical events; subtle flicker when panning across mixed-history tiles.
-
Where:
HeatmapTile.add -
Why:
ageInSecondsis computed relative to the minimum event time in the tile. Two neighboring tiles with different min-times will decay contributions differently for the same absolute timestamps. -
Evidence:
val sortedList = list.sortedBy { it.time } val minTime = sortedList.first().time // ... val ageInSeconds = ((location.time - minTime) / Time.SECOND_IN_MILLISECONDS).toInt()
-
Fix suggestion: Use a common reference (e.g., median tile time or a viewport frame time) for age, or pass in a frame-time from the layer and compute
age = frameTime - sampleTime.
-
Impact: Small temporal inconsistencies and micro-flicker as the “neighbor-aware density” gain changes based on previously-added samples.
-
Where:
AgeWeightedHeatmap.addPoint -
Why:
densityGainsamples the currentweightArrayaround the target pixel before adding this point and modulates the contribution. This makes results depend on insertion order and local sample timing. -
Evidence:
// AgeWeightedHeatmap.addPoint val densityRadius = max(2, min(halfStampWidth, halfStampHeight)) // ... accumulate densitySum from weightArray[] var densityGain = if (localMean > 0f) localMean / (localMean + denomBase) else 0f densityGain = kotlin.math.sqrt(densityGain) val minGain = 0.45f densityGain = minGain + (1f - minGain) * densityGain // merged = weightMerge(..., stampValue * ... * densityGain, ...)
-
Fix suggestion: Make density gain a post-pass smoothing (e.g., small blur in normalized space, already present) or compute a static density map first, then deposit in a separate pass to remove order dependence.
-
Impact: Confusing interplay, unexpected opacity; weight EMA depending on
currentAlphawhile final tile alpha comes from normalized intensity whenalphaFromNormalized=true. -
Where: Layer configs (
LocationHeatmapLayer,SpeedHeatmapLayer,WifiHeatmapLayer) andAgeWeightedHeatmap.renderFromNormalized. -
Why:
alphaMergeFunctionandalphaArrayaffectcurrentAlphaseen byweightMergeFunction(e.g., EMA in Speed), but the final bitmap alpha is derived from normalized value ifalphaFromNormalized=true. This asymmetry can be surprising and makes opacity tuning non-intuitive. -
Evidence:
// renderFromNormalized val alpha = if (alphaFromNormalized) { (opacity * n * 255).roundToInt().coerceIn(0, 255) } else { alphaArray[index].toInt().coerceIn(0, 255) }
-
Fix suggestion: Either switch to
alphaFromNormalized=falsewhere alpha behavior matters, or remove alpha-driven EMA from weight merge and use a simpler weight integrator. Align config with intended visuals.
-
Impact: Unnecessary allocations and GC churn; can cause stutter/glitches during fast pans/zooms.
-
Where:
HeatmapTile.toByteArray -
Why: The function always creates new bitmaps (
Bitmap.createBitmap) and only optionally accepts aBitmapPoolparameter in signature; it never reuses a pooled instance. -
Evidence:
val base = Bitmap.createBitmap(paddedSize, paddedSize, Bitmap.Config.ARGB_8888) // ... val cropped = Bitmap.createBitmap(base, data.pad, data.pad, data.heatmapSize, data.heatmapSize) val scaled = if (data.heatmapSize != bitmapSize) cropped.scale(bitmapSize, bitmapSize, true) else cropped
-
Fix suggestion: Introduce pool usage for base/cropped/scaled bitmaps and recycle via the pool, or render directly into a pooled 256×256 bitmap to avoid intermediate images.
-
Impact: Slight mismatch of neighborhood sample bins (can bias normalization near borders).
-
Where:
LocationHeatmapLayer,SpeedHeatmapLayer,WifiHeatmapLayer– neighborAgg block -
Why: Mapping
[x-1, x+2)tiles to a 0..normSize buffer usestoInt()(truncation). This can introduce a half-pixel bias near edges. -
Evidence:
val localX = (((txN - (x - 1)) / 3.0) * normSize).toInt() val localY = (((tyN - (y - 1)) / 3.0) * normSize).toInt()
-
Fix suggestion: Use
floor()and clamp to[0, normSize-1]for binning stability.
- Use a smooth blur strength curve based on
coverageinstead of a step at 0.25. - Reduce cutoff discontinuity by lerping cutoff in a small window around the threshold.
- Prefer median time (or viewport-stable frame time) for
ageInSecondsbaseline to reduce tile-to-tile time-span bias. - Where you need consistent appearance across zoom levels, consider a constant tile-pixel radius (optional mode already scaffolded in
LocationHeatmapLayer).
map/src/main/java/com/adsamcik/tracker/map/heatmap/HeatmapTile.ktmap/src/main/java/com/adsamcik/tracker/map/heatmap/implementation/AgeWeightedHeatmap.ktmap/src/main/java/com/adsamcik/tracker/map/layers/impl/LocationHeatmapLayer.ktmap/src/main/java/com/adsamcik/tracker/map/layers/impl/SpeedHeatmapLayer.ktmap/src/main/java/com/adsamcik/tracker/map/layers/impl/WifiHeatmapLayer.ktmap/src/main/java/com/adsamcik/tracker/map/layers/impl/CellHeatmapLayer.ktmap/src/main/java/com/adsamcik/tracker/map/MapFunctions.ktmap/src/test/java/com/adsamcik/tracker/map/heatmap/engine/HeatTileEngineSeamTest.kt
Primary visual glitches are caused by: coordinate rounding at tile borders, over-clamping the neighborhood normalization, hard on/off blur and cutoff, and deposition choices that ignore stamp falloff. Addressing items 1–5 typically removes seams and flicker and yields a smoother, more consistent heatmap.