Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,17 @@ Feedback, issues, and contributions are welcome.
## License

MapConductor Android SDK is released under the Apache License 2.0.

## Bulk circles and raster tiles

Inside map content, use `Circles(states)` from `com.mapconductor.compose.circle` to register a collection in one operation. Each collection removes only its own IDs. Use `CircleTiling(CircleTilingOptions.Disabled)` to keep native rendering, or `CircleTiling(CircleTilingOptions(minCircleCount = 5000))` to customize the threshold. Options are in `com.mapconductor.core.circle`.

By default, raster tiling starts above 2,000 tileable circles. Large collections keep their drawing geometry in a disk-backed cache. Circle radius remains a ground distance in metres and stroke width remains a screen distance. Clicks and `onAdded` use the original states. `onAdded` indicates that the tile layer was handed to the map.

Circles crossing the antimeridian, reaching Mercator's latitude limit, or having invalid radii stay native. Circle tiles stack above polygons and below polylines.

## US antenna horizons example

Choose **US Antenna Horizons** in the example sidebar. It bundles 723,800 OpenCellID records (MCC 310–314), with carrier filters and US and Alaska camera presets. Each estimated cell position is drawn as a 500 m geodesic circle. The radius is a display setting and does not model signal coverage.

Cell tower data from [OpenCellID](https://opencellid.org) · [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
2 changes: 1 addition & 1 deletion android-sdk-core
Submodule android-sdk-core updated 39 files
+113 −2 api/android-sdk-core.api
+1 −0 build.gradle.kts
+12 −0 src/androidTest/assets/antennas/README.md
+ − src/androidTest/assets/antennas/us-antennas.bin
+16 −0 src/androidTest/assets/antennas/us-antennas.json
+68 −0 src/androidTest/java/com/mapconductor/core/circle/CircleNationwidePerformanceTest.kt
+167 −0 src/androidTest/java/com/mapconductor/core/circle/CircleTileFileDeviceTest.kt
+414 −0 src/androidTest/java/com/mapconductor/core/circle/CircleTileRendererDeviceTest.kt
+346 −0 src/androidTest/java/com/mapconductor/core/circle/CircleTilerDeviceTest.kt
+39 −0 src/androidTest/java/com/mapconductor/core/tileserver/LocalTileServerCancellationTest.kt
+11 −5 src/main/java/com/mapconductor/core/OverlayAddedTracker.kt
+21 −17 src/main/java/com/mapconductor/core/OverlayCollector.kt
+20 −0 src/main/java/com/mapconductor/core/OverlayStateMaps.kt
+88 −20 src/main/java/com/mapconductor/core/circle/Circle.kt
+104 −9 src/main/java/com/mapconductor/core/circle/CircleController.kt
+9 −1 src/main/java/com/mapconductor/core/circle/CircleEntity.kt
+89 −27 src/main/java/com/mapconductor/core/circle/CircleManager.kt
+27 −14 src/main/java/com/mapconductor/core/circle/CircleOverlay.kt
+306 −0 src/main/java/com/mapconductor/core/circle/CircleTileFileStorage.kt
+323 −0 src/main/java/com/mapconductor/core/circle/CircleTileGeometry.kt
+121 −0 src/main/java/com/mapconductor/core/circle/CircleTileSet.kt
+95 −0 src/main/java/com/mapconductor/core/circle/CircleTiler.kt
+28 −0 src/main/java/com/mapconductor/core/circle/CircleTilingOptions.kt
+6 −0 src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt
+2 −2 src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt
+171 −90 src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt
+510 −417 src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt
+286 −166 src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt
+72 −0 src/main/java/com/mapconductor/core/shapetile/ShapeTileStorage.kt
+2 −1 src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt
+40 −10 src/main/java/com/mapconductor/core/tileserver/LocalTileServer.kt
+39 −0 src/test/java/com/mapconductor/core/OverlayCollectorMutationTest.kt
+21 −0 src/test/java/com/mapconductor/core/OverlayStateMapsTest.kt
+133 −0 src/test/java/com/mapconductor/core/circle/CircleHitTest.kt
+140 −0 src/test/java/com/mapconductor/core/circle/CircleTileFileStorageTest.kt
+268 −0 src/test/java/com/mapconductor/core/circle/CircleTilingTest.kt
+56 −0 src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt
+17 −8 src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt
+54 −0 src/test/java/com/mapconductor/core/shapetile/ShapeGridIndexTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.mapconductor.example

import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getAllSemanticsNodes
import androidx.compose.ui.semantics.getOrNull
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.camera.CameraUpdateFactory
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import android.content.Intent
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.view.ViewGroup

/** Full nationwide data, native camera zooms, repeated carrier removal/restoration. */
@RunWith(AndroidJUnit4::class)
class CircleFilteringStressTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()

private fun <T : Any> onMain(block: () -> T): T {
var result: T? = null
instrumentation.runOnMainSync { result = block() }
return result!!
}

private fun activity(): MainActivity =
ActivityLifecycleMonitorRegistry
.getInstance()
.getActivitiesInStage(Stage.RESUMED)
.filterIsInstance<MainActivity>()
.single()

// Read Compose semantics directly. This leaves the app's real frame clock
// running and avoids depending on the device accessibility service cache.
private fun nodes(view: View): Sequence<SemanticsNode> =
sequence {
if (view is ViewRootForTest) yieldAll(view.semanticsOwner.getAllSemanticsNodes(false))
if (view is ViewGroup) for (index in 0 until view.childCount) yieldAll(nodes(view.getChildAt(index)))
}

private fun toggleVerizon() =
onMain {
val checkboxes =
nodes(activity().window.decorView)
.filter { it.config.getOrNull(SemanticsProperties.ToggleableState) != null }
.sortedBy { it.boundsInRoot.top }
.toList()
assertTrue(checkboxes.size >= 4)
assertTrue(checkboxes[2].config[SemanticsActions.OnClick].action!!.invoke())
}

private fun waitForCount(count: Int) {
val deadline = SystemClock.uptimeMillis() + 60_000
var lastTexts = emptyList<String>()
while (SystemClock.uptimeMillis() < deadline) {
lastTexts =
onMain {
nodes(activity().window.decorView)
.mapNotNull {
it.config.getOrNull(SemanticsProperties.Text)?.joinToString("") { text -> text.text }
}.toList()
}
if (lastTexts.any { it == "$count / 723800 circles" }) return
SystemClock.sleep(250)
}
throw AssertionError("Circle count did not reach $count; semantics texts: $lastTexts")
}

private fun mapView(view: View): MapView? {
if (view is MapView) return view
if (view is ViewGroup) for (i in 0 until view.childCount) mapView(view.getChildAt(i))?.let { return it }
return null
}

@Test
fun zoomAndToggleVerizonFiveTimes() {
val intent =
Intent(ApplicationProvider.getApplicationContext(), MainActivity::class.java).apply {
putExtra("page", "antenna-horizons")
putExtra("provider", "maplibre")
}
ActivityScenario.launch<MainActivity>(intent).use {
waitForCount(723800)
SystemClock.sleep(5_000)
lateinit var map: MapLibreMap
val ready = CountDownLatch(1)
onMain {
val view = mapView(activity().window.decorView)!!
view.getMapAsync {
map = it
ready.countDown()
}
}
assertTrue(ready.await(10, TimeUnit.SECONDS))

fun zoom(): Double {
var result = 0.0
instrumentation.runOnMainSync { result = map.cameraPosition.zoom }
return result
}
repeat(5) {
Log.i("CircleFilterStress", "Starting cycle ${it + 1}")
val before = zoom()
onMain { map.animateCamera(CameraUpdateFactory.zoomTo(before + 2), 300) }
SystemClock.sleep(1_000)
assertTrue("Native map must actually zoom in", zoom() > before + 0.5)
onMain { map.animateCamera(CameraUpdateFactory.zoomTo(before), 300) }
SystemClock.sleep(1_000)
assertTrue("Native map must actually zoom out", zoom() < before + 0.5)
toggleVerizon()
waitForCount(576636)
SystemClock.sleep(3_000)
toggleVerizon()
waitForCount(723800)
SystemClock.sleep(3_000)
Log.i("CircleFilterStress", "Completed cycle ${it + 1}")
}
}
}
}
20 changes: 20 additions & 0 deletions example-app/src/main/assets/antennas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# OpenCellID cell positions (MCC 310–314)

Cell tower data from [OpenCellID](https://opencellid.org) · [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)

Derived from user-supplied 310.csv.gz through 314.csv.gz. All 723,800 rows are preserved as separate cells (not unique physical towers). Coordinates are rounded to five decimals and stored as signed E5 integers; range is float32. No token is shipped.

Circles automatically use the shared ShapeTiler and RasterLayer infrastructure above 2,000 circles. No tiling parameter is passed by the example.

Tiled circles share the tile source's states and ID lookup with the hit manager; they do not retain a CircleEntity or Fingerprint per cell. Native changes use revision numbers and attribute bit flags. Legacy fingerprint snapshots remain available for native-provider compatibility. Cheap circle projections are recomputed for requested tiles instead of retaining a full population at every zoom.

Carrier counts and MCC counts are in us-antennas.json. Classification uses the MCC/MNC pair.
Assignment source: https://imsiadmin.com/assignments/hni/ (2026-11-10). Classification is by MCC/MNC assignment, not tower ownership. CDMA SID is not interpreted as MNC. Supplied files do not represent a complete physical tower inventory.

Each cell is drawn as a fixed 500 m radius circle to indicate its estimated position, not signal coverage. The CSV range is retained in the asset but is not used as a display radius. Geodesic=true uses spherical distance. All cells for the selected carriers are rendered without sampling or a count limit; deselecting all carriers renders no circles. Carrier colors: blue, pink, red, gray.

Binary: MCCE + u32 little-endian count, then 16-byte records: i32 latitude_e5, i32 longitude_e5, f32 range_m, u32 (carrier_index * 1000 + MNC). Stable row index identifies each cell. Carrier indices: AT&T=0, T-Mobile/Sprint=1, Verizon=2, Others=3.

Reproduce with pack-opencellid.py 310.csv.gz 311.csv.gz 312.csv.gz 313.csv.gz 314.csv.gz --output OUTPUT.bin [--output OUTPUT.bin ...]. pack.py is the older FCC importer and is not used for this dataset. Modifications and binary data remain under CC BY-SA 4.0; retain the attribution above when redistributing.

CircleState.extra holds only the source row ID. A shared click handler resolves details from the source table. Subpixel circles sharing a world pixel use the topmost visible circle as their display representative; their alpha no longer accumulates at that scale. Source states and hit testing remain intact, and zooming in restores every circle.
Binary file not shown.
31 changes: 31 additions & 0 deletions example-app/src/main/assets/antennas/us-antennas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"source": "https://opencellid.org",
"license": "https://creativecommons.org/licenses/by-sa/4.0/",
"attribution": "Cell tower data from OpenCellID \u00b7 CC BY-SA 4.0",
"inputs": [
"310.csv.gz",
"311.csv.gz",
"312.csv.gz",
"313.csv.gz",
"314.csv.gz"
],
"count": 723800,
"carrier_counts": [
199569,
372599,
147164,
4468
],
"mcc_counts": {
"310": 567764,
"311": 150228,
"312": 199,
"313": 5349,
"314": 260
},
"file_sha256": "eda2bc13c14fe4d29ee94b4e6a92d6b8be14566ef04a95cb6aaf2954b456acec",
"bytes": 11580808,
"format": "MCCE + u32 count + records (i32 latitude_e5, i32 longitude_e5, f32 range_m, u32 carrier*1000+MNC), little endian",
"classification_source": "https://imsiadmin.com/assignments/hni/",
"coverage": "Supplied MCC 310-314 files; not a complete inventory of physical towers"
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import com.mapconductor.example.pages.polygon.geodesic.PolygonGeodesicPage
import com.mapconductor.example.pages.polygon.hole.HolePolygonMapPage
import com.mapconductor.example.pages.polyline.PolylineClickMapPage
import com.mapconductor.example.pages.polyline.PolylineMapPage
import com.mapconductor.example.pages.circle.AntennaHorizonsPage
import com.mapconductor.example.pages.polyline.boston.BostonRoadsPage
import com.mapconductor.example.pages.rasterlayer.RasterHeaderProbePage
import com.mapconductor.example.pages.rasterlayer.RasterLayerMapPage
Expand Down Expand Up @@ -132,7 +133,7 @@ fun DemoAppScreen(initPage: String = "map") {
),
SidebarSection(
title = "Circle",
items = listOf(SidebarItem(id = "circle", title = "Circle")),
items = listOf(SidebarItem(id = "circle", title = "Circle"), SidebarItem(id = "antenna-horizons", title = "US Antenna Horizons")),
),
SidebarSection(
title = "Ground Image",
Expand Down Expand Up @@ -342,6 +343,7 @@ fun DemoAppScreen(initPage: String = "map") {
onToggleSidebar = navigationViewModel::toggleSidebar,
)
}
"antenna-horizons" -> { AntennaHorizonsPage(onToggleSidebar = navigationViewModel::toggleSidebar) }
"boston-roads" -> {
BostonRoadsPage(
onToggleSidebar = navigationViewModel::toggleSidebar,
Expand Down
Loading