diff --git a/android-for-arcgis b/android-for-arcgis index 9eca2e67..994a6fe9 160000 --- a/android-for-arcgis +++ b/android-for-arcgis @@ -1 +1 @@ -Subproject commit 9eca2e6702c175595fdc1df5acc7ae45561c8e77 +Subproject commit 994a6fe99a6ceb20f978c5d77e18f8e8ede785db diff --git a/android-for-googlemaps b/android-for-googlemaps index 1d626603..0613e2be 160000 --- a/android-for-googlemaps +++ b/android-for-googlemaps @@ -1 +1 @@ -Subproject commit 1d6266036bd045d2485e80266ee248b99c3edf45 +Subproject commit 0613e2be25f476e8be291fd65a2fcc05d5715246 diff --git a/android-for-here b/android-for-here index d94b6ac7..45c8b128 160000 --- a/android-for-here +++ b/android-for-here @@ -1 +1 @@ -Subproject commit d94b6ac7ed31e2d4bb3da1eace2f087e33cf81ec +Subproject commit 45c8b1289db15d4e09a5b9c7aacb29c23665830f diff --git a/android-for-longdo b/android-for-longdo index 9a148abf..2c722a80 160000 --- a/android-for-longdo +++ b/android-for-longdo @@ -1 +1 @@ -Subproject commit 9a148abfea4b6d5217f1fce94950623761ef457a +Subproject commit 2c722a80f1d58175d240404636a837bdda333439 diff --git a/android-for-mapbox b/android-for-mapbox index 99ef8c3c..a5d21219 160000 --- a/android-for-mapbox +++ b/android-for-mapbox @@ -1 +1 @@ -Subproject commit 99ef8c3cc2d4b0f4c071247613ac1ce891b3b68c +Subproject commit a5d212192aff08f4909674e60345caece118c1cc diff --git a/android-for-maplibre b/android-for-maplibre index f78f720a..23b58380 160000 --- a/android-for-maplibre +++ b/android-for-maplibre @@ -1 +1 @@ -Subproject commit f78f720a958336a13902f2bb77b4b19e93c6b5ce +Subproject commit 23b58380defb95554e9ece8be45c437d6d14e0e5 diff --git a/android-for-mappls b/android-for-mappls index 1d52e9d1..87cddcc8 160000 --- a/android-for-mappls +++ b/android-for-mappls @@ -1 +1 @@ -Subproject commit 1d52e9d1f0043aea748b8ee3822dcf636550714f +Subproject commit 87cddcc87b7cd613d3c235ea91fe853e507fba0e diff --git a/android-for-maptiler b/android-for-maptiler index 26c40f83..ee131350 160000 --- a/android-for-maptiler +++ b/android-for-maptiler @@ -1 +1 @@ -Subproject commit 26c40f83c9dade00c91a4e0c8b30d74807b99f9e +Subproject commit ee1313505c93c56fea46be371a10c18d5c67a854 diff --git a/android-for-openmobilemaps b/android-for-openmobilemaps index f15c1b30..f57f996e 160000 --- a/android-for-openmobilemaps +++ b/android-for-openmobilemaps @@ -1 +1 @@ -Subproject commit f15c1b304135f99de4f0720199853acf3d9aba20 +Subproject commit f57f996e1dccd29dc1789356052a2127b75efecd diff --git a/android-for-tomtom b/android-for-tomtom index df6981fd..9910ef43 160000 --- a/android-for-tomtom +++ b/android-for-tomtom @@ -1 +1 @@ -Subproject commit df6981fd1182aed235b62ad3b4c9ea236d86466b +Subproject commit 9910ef4330b36f0c0506130ce68dc5c81e72a87d diff --git a/android-sdk-compose b/android-sdk-compose index 2f919ec1..60d3a460 160000 --- a/android-sdk-compose +++ b/android-sdk-compose @@ -1 +1 @@ -Subproject commit 2f919ec10141cf7a1736a7a4a645ac542c188f0a +Subproject commit 60d3a460bfd4e2f600aa1a6cd5ea1bd275e1cf19 diff --git a/android-sdk-core b/android-sdk-core index b196cb3a..9bff0dad 160000 --- a/android-sdk-core +++ b/android-sdk-core @@ -1 +1 @@ -Subproject commit b196cb3a6cd54cf42af2a94804381ca5018bd201 +Subproject commit 9bff0dad0811d2156ddf1e9e3bc8f060a929a21b diff --git a/example-app/src/main/assets/boston/boston-city-roads.bin b/example-app/src/main/assets/boston/boston-city-roads.bin new file mode 100644 index 00000000..87cfa744 Binary files /dev/null and b/example-app/src/main/assets/boston/boston-city-roads.bin differ diff --git a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt index e145ad85..0c4463d9 100644 --- a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt +++ b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt @@ -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.polyline.boston.BostonRoadsPage import com.mapconductor.example.pages.rasterlayer.RasterHeaderProbePage import com.mapconductor.example.pages.rasterlayer.RasterLayerMapPage import com.mapconductor.example.pages.startup.StartUpPage @@ -143,6 +144,7 @@ fun DemoAppScreen(initPage: String = "map") { listOf( SidebarItem(id = "polyline", title = "Polyline"), SidebarItem(id = "polyline-click", title = "Polyline Click"), + SidebarItem(id = "boston-roads", title = "Boston Roads"), ), ), SidebarSection( @@ -340,6 +342,11 @@ fun DemoAppScreen(initPage: String = "map") { onToggleSidebar = navigationViewModel::toggleSidebar, ) } + "boston-roads" -> { + BostonRoadsPage( + onToggleSidebar = navigationViewModel::toggleSidebar, + ) + } "groundImage" -> { GroundImageMapPage( groundImageResources = groundImageResources, diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonBuildingsViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonBuildingsViewModel.kt index 4551e042..80a7e5a8 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonBuildingsViewModel.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonBuildingsViewModel.kt @@ -33,6 +33,8 @@ class Building( val rings: List, /** A parcel's land use, from the City of Boston's Property Assessment. */ val use: LandUse? = null, + /** A road's class (the roads file): the index in [com.mapconductor.example.pages.polyline.boston.RoadClass]. */ + val roadClass: Int? = null, ) /** What the last tap hit: a building, or nothing (`building` null), and where it landed. */ diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonData.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonData.kt index bbe2364d..a5e769f3 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonData.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonData.kt @@ -22,18 +22,22 @@ import android.content.Context * * The binary layout is the same as the React sample's (bostonData.ts), MCPG v2: * 'MCPG' u32 version=2 u32 count u8 fields u8 digits (little-endian) - * fields: 1 = height, 2 = land use, 4 = area, 8 = name (what the file carries) + * fields: 1 = height, 2 = land use, 4 = area, 8 = name, 16 = road class (what the file carries), + * 32 = lines: the shapes are polylines, not polygons (the roads file, see BostonRoadsData) * digits: coordinates are integers of 10^-digits degree (buildings 6, parcels 5) * per polygon, only the fields present, in this order: * height varint, 0 = unknown, else the height in 0.1 m plus 1 * land use u8, the index in [LandUse] (family … unknown) * area u8, the smallest downtown area (0, 1 or 2) the building fits in * name varint byte length (0 = none), UTF-8 + * road class u8, the index in RoadClass (highway … path) * varint ringCount, per ring: varint n, n x (svarint dLat, svarint dLng) * varint is LEB128 (7 bits at a time, low first); svarint is a zigzag varint. * Coordinates are integers of 10^-digits degree, each the difference from the * previous vertex across the whole file. A ring's closing vertex (the same as its first) * is not stored: n counts the others, and the reader closes the ring again. + * With the lines bit a "ring" is an open polyline: every vertex is stored and + * the reader does not close it. */ enum class Dataset( val label: String, @@ -128,8 +132,10 @@ private const val FIELD_HEIGHT = 1 private const val FIELD_USE = 2 private const val FIELD_AREA = 4 private const val FIELD_NAME = 8 +private const val FIELD_CLASS = 16 +private const val FIELD_LINES = 32 -/** Decodes an MCPG v2 file (see the top of this file); polygon `i` gets the id "-". */ +/** Decodes an MCPG v2 file (see the top of this file); shape `i` gets the id "-". */ internal fun decodeMcpg( bytes: ByteArray, idPrefix: String, @@ -141,6 +147,7 @@ internal fun decodeMcpg( val fields = bytes[12].toInt() and 0xFF val scale = Math.pow(10.0, (bytes[13].toInt() and 0xFF).toDouble()) var offset = 14 + val lines = (fields and FIELD_LINES) != 0 fun varint(): Int { var value = 0 @@ -165,6 +172,7 @@ internal fun decodeMcpg( var use: LandUse? = null var level = 0 var name: String? = null + var roadClass: Int? = null if ((fields and FIELD_HEIGHT) != 0) height = varint().takeIf { it > 0 }?.let { (it - 1) / 10.0 } if ((fields and FIELD_USE) != 0) use = LandUse.entries[bytes[offset++].toInt() and 0xFF] if ((fields and FIELD_AREA) != 0) level = bytes[offset++].toInt() and 0xFF @@ -173,22 +181,25 @@ internal fun decodeMcpg( if (length > 0) name = String(bytes, offset, length, Charsets.UTF_8) offset += length } + if ((fields and FIELD_CLASS) != 0) roadClass = bytes[offset++].toInt() and 0xFF val rings = List(varint()) { val n = varint() - // The closing vertex is not stored; it is the first one again. - val ring = DoubleArray((n + 1) * 2) + // The closing vertex is not stored; it is the first one again. A line is open. + val ring = DoubleArray((if (lines) n else n + 1) * 2) for (v in 0 until n) { lat += svarint() lng += svarint() ring[v * 2] = lng / scale ring[v * 2 + 1] = lat / scale } - ring[n * 2] = ring[0] - ring[n * 2 + 1] = ring[1] + if (!lines) { + ring[n * 2] = ring[0] + ring[n * 2 + 1] = ring[1] + } ring } - Building(id = "$idPrefix-$i", level = level, height = height, name = name, rings = rings, use = use) + Building(id = "$idPrefix-$i", level = level, height = height, name = name, rings = rings, use = use, roadClass = roadClass) } } diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonOverlays.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonOverlays.kt index adee1bd0..2a188b1a 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonOverlays.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/boston/BostonOverlays.kt @@ -54,6 +54,8 @@ private const val MB = 1024.0 * 1024.0 fun LoadingOverlay( phase: LoadPhase, modifier: Modifier = Modifier, + /** What is loading, for the text: "polygons" or "roads". */ + shapes: String = "polygons", ) { var fraction: Float? = null val text = @@ -68,8 +70,8 @@ fun LoadingOverlay( "Loading data… $loaded MB" } } - LoadPhase.Decode -> "Reading the polygons…" - LoadPhase.Build -> "Preparing the polygons…" + LoadPhase.Decode -> "Reading the $shapes…" + LoadPhase.Build -> "Preparing the $shapes…" LoadPhase.Draw -> "Drawing the map…" } Box( diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsData.kt b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsData.kt new file mode 100644 index 00000000..ada8a577 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsData.kt @@ -0,0 +1,87 @@ +package com.mapconductor.example.pages.polyline.boston + +import androidx.compose.ui.graphics.Color +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.example.pages.polygon.boston.LoadPhase +import com.mapconductor.example.pages.polygon.boston.decodeMcpg +import android.content.Context + +/** + * The roads the Boston roads page draws: every road of the city, from Overture + * Maps `transportation/segment` (road subtype), cut at the city boundary. 54,645 + * polylines, 234,765 points, about 1 MB. + * + * Bundled as assets/boston/boston-city-roads.bin. The React sample's + * `examples/basic/tools/boston/` builds it (`pack.py roads`, see its README for + * the source, licence and why not Analyze Boston's street centerlines). The file + * carries no ids: this is a polyline demo, and a road's id on the map is its + * index in the file. + * + * The format is the polygons' MCPG v2 (see BostonData.kt) with two more header + * bits: 16, the road class (u8, after the name), and 32, lines: each record's + * one "ring" is an open polyline, every vertex stored, never closed by the + * reader. Coordinates are 1e-5 degree; points almost on a straight line (0.5 m) + * are left out, the ends kept. + */ +const val ROADS_FILE = "boston/boston-city-roads.bin" + +const val ROADS_ATTRIBUTION = + "© OpenStreetMap contributors, TomTom — via Overture Maps Foundation (ODbL 1.0)" + +/** + * A road's class (the file's index, grouped from Overture's segment classes) and + * how the page draws it: bigger roads wider, brighter and on top. The same table + * on the web and iOS. + */ +enum class RoadClass( + val label: String, + val color: Color, + /** dp. */ + val width: Float, + val zIndex: Int, +) { + HIGHWAY("Highway", Color(0xFFE8590C), 5f, 6), + PRIMARY("Primary", Color(0xFFF08C00), 4f, 5), + SECONDARY("Secondary", Color(0xFFFAB005), 3.5f, 4), + TERTIARY("Tertiary", Color(0xFFFFD43B), 3f, 3), + LOCAL("Local", Color(0xFF868E96), 2f, 2), + SERVICE("Service", Color(0xFFADB5BD), 1.5f, 1), + PATH("Path", Color(0xFF74B816), 1f, 0), +} + +class Road( + val id: String, + val roadClass: RoadClass, + val name: String?, + /** `[lng0, lat0, lng1, lat1, ...]`, open. */ + val path: DoubleArray, +) { + val points: List get() = List(path.size / 2) { GeoPoint(path[it * 2 + 1], path[it * 2]) } +} + +/** Decodes the roads file: MCPG v2 with the road class and lines bits; road `i` gets the id "road-". */ +fun decodeRoads(bytes: ByteArray): List = + decodeMcpg(bytes, "road").map { shape -> + Road( + id = shape.id, + roadClass = RoadClass.entries[shape.roadClass ?: RoadClass.LOCAL.ordinal], + name = shape.name, + path = shape.rings.single(), + ) + } + +fun loadBostonRoads( + context: Context, + onProgress: (LoadPhase) -> Unit = {}, +): List { + val length = runCatching { context.assets.openFd(ROADS_FILE).use { it.length } }.getOrNull() + onProgress(LoadPhase.Read(0, length)) + val bytes = context.assets.open(ROADS_FILE).use { it.readBytes() } + onProgress(LoadPhase.Read(bytes.size.toLong(), length)) + onProgress(LoadPhase.Decode) + return decodeRoads(bytes) +} + +/** The road's length in metres: the geodesic length of its points. */ +fun lengthMetres(road: Road): Double = Spherical.computeLength(road.points) diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsPage.kt new file mode 100644 index 00000000..bff7a65d --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsPage.kt @@ -0,0 +1,274 @@ +package com.mapconductor.example.pages.polyline.boston + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapconductor.compose.MapViewScope +import com.mapconductor.compose.circle.Circle +import com.mapconductor.compose.info.InfoBubble +import com.mapconductor.compose.marker.Marker +import com.mapconductor.compose.polygon.Polygons +import com.mapconductor.compose.polyline.PolylineTiling +import com.mapconductor.compose.polyline.Polylines +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.example.MapViewContainer +import com.mapconductor.example.pages.polygon.boston.LoadingOverlay +import com.mapconductor.example.ui.DefaultMapViewItems +import com.mapconductor.example.ui.DemoMapPageScaffold +import com.mapconductor.example.ui.MessageCard +import java.util.Locale +import android.app.Activity + +@Composable +fun BostonRoadsPage( + modifier: Modifier = Modifier, + onToggleSidebar: () -> Unit = {}, +) { + val context = LocalContext.current + // `adb shell am start ... --es page boston-roads --es camera "42.3555,-71.0605,15" --es tiling off` + // opens a fixed view, so a measurement sees the same thing on every provider. + val viewModel = + remember { + val intent = (context as? Activity)?.intent + BostonRoadsViewModel( + context = context.applicationContext, + initCamera = intent?.getStringExtra("camera"), + initTiling = + when (intent?.getStringExtra("tiling")) { + "off" -> false + "on" -> true + else -> null + }, + initOverlap = intent?.getStringExtra("overlap") == "on", + ) + } + + DemoMapPageScaffold( + menuItems = DefaultMapViewItems(viewModel.initCameraPosition), + onToggleSidebar = onToggleSidebar, + onMapViewStateChanged = viewModel::onMapViewChanged, + ) { paddingValues -> + val mapViewState = viewModel.mapViewState.collectAsState() + val states = viewModel.polylineStates.collectAsState() + val tiling = viewModel.tiling.collectAsState() + val overlap = viewModel.overlap.collectAsState() + val loadError = viewModel.loadError.collectAsState() + val stats = viewModel.stats.collectAsState() + val tap = viewModel.tap.collectAsState() + val progress = viewModel.progress.collectAsState() + + // Two frames after the list reaches Polylines(), for the stats. The loading overlay waits for onAdded. + LaunchedEffect(states.value) { + val shown = states.value + withFrameNanos {} + withFrameNanos {} + viewModel.onDrawn(shown) + } + + mapViewState.value?.let { + MapViewContainer( + state = it, + modifier = modifier.padding(paddingValues), + onMapClick = viewModel::onMapClick, + ) { + // Before Polylines(): read when the roads are handed over. + PolylineTiling(viewModel.tilingOptions(tiling.value)) + Polylines(states.value) + if (overlap.value) OverlapCheck() + val shownTap = tap.value + val road = shownTap?.road + val point = shownTap?.point + if (road != null && point != null) { + InfoBubble(position = point, borderWidth = 1.dp, borderColor = Color(0x33000000)) { + RoadInfo(road) + } + } + } + } + + progress.value?.let { LoadingOverlay(it, modifier = Modifier.padding(paddingValues), shapes = "roads") } + + Column( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(top = paddingValues.calculateTopPadding() + 16.dp, start = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(true to "Tiles", false to "Native").forEach { (tiled, label) -> + if (tiling.value == tiled) { + Button(onClick = {}) { Text(label) } + } else { + FilledTonalButton(onClick = { viewModel.setTiling(tiled) }) { Text(label) } + } + } + if (overlap.value) { + Button(onClick = { viewModel.setOverlap(false) }) { Text("Overlap check") } + } else { + FilledTonalButton(onClick = { viewModel.setOverlap(true) }) { Text("Overlap check") } + } + } + } + + MessageCard( + title = + stats.value?.let { "Boston roads: ${it.roads} polylines, ${it.points} points" } + ?: "Boston roads", + isCardOpen = true, + maxHeight = 300.dp, + modifier = + Modifier + .align(Alignment.BottomStart) + .widthIn(max = 480.dp) + .padding( + bottom = paddingValues.calculateBottomPadding() + 16.dp, + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr) + 16.dp, + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr) + 16.dp, + ), + ) { + loadError.value?.let { Text(it) } + RoadClassLegend(modifier = Modifier.padding(vertical = 4.dp)) + stats.value?.let { + val how = if (it.tiled) "tiles" else "native" + val frame = it.frameMillis?.let { ms -> ", next frame $ms ms" } ?: "" + val added = it.addedMillis?.let { ms -> ", on the map $ms ms" } ?: "" + Text("[$how] Load ${it.loadMillis} ms, states ${it.buildMillis} ms$frame$added") + } + tap.value?.let { tap -> + val road = tap.road + Text( + if (road == null) { + "No road here" + } else { + "${road.name ?: road.roadClass.label} · ${road.roadClass.label} · ${formatLength(lengthMetres(road))}" + }, + ) + } + Text(ROADS_ATTRIBUTION, fontSize = 11.sp) + } + } +} + +/** The road classes: a line as wide and as coloured as on the map, two to a row. */ +@Composable +fun RoadClassLegend(modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + RoadClass.entries.chunked(2).forEach { row -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { roadClass -> + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .width(18.dp) + .height(roadClass.width.dp) + .background(roadClass.color, RoadedEnds), + ) + Text(roadClass.label, fontSize = 11.sp, modifier = Modifier.padding(start = 6.dp)) + } + } + if (row.size == 1) Box(modifier = Modifier.weight(1f)) + } + } + } +} + +private val RoadedEnds = RoundedCornerShape(50) + +/** What a tapped road carries, shown in an InfoBubble at the point on the road nearest the tap. */ +@Composable +fun RoadInfo(road: Road) { + val rows = mutableListOf>() + rows += "Class" to road.roadClass.label + road.name?.let { rows += "Name" to it } + rows += "Length" to formatLength(lengthMetres(road)) + rows += "Points" to "${road.path.size / 2}" + Column { + rows.forEach { (key, value) -> + Row { + Text( + key, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF475569), + modifier = Modifier.width(64.dp), + ) + Text(value, fontSize = 12.sp, color = Color(0xFF0F172A)) + } + } + } +} + +/** "123 m", or kilometres from 1,000 m up. */ +fun formatLength(metres: Double): String = + if (metres >= 1000) { + String.format(Locale.US, "%.2f km", metres / 1000) + } else { + String.format(Locale.US, "%,.0f m", metres) + } + +/** + * The stacking check: over the roads downtown, a grid of 2,500 small polygons + * (enough to be drawn as polygon tiles), a circle and a marker. From the bottom + * the map shows polygon tiles, the circle, the road tiles, then the marker. + */ +@Composable +private fun MapViewScope.OverlapCheck() { + val polygons = + remember { + List(2500) { i -> + val lat = 42.3500 + (i / 50) * 0.0002 + val lng = -71.0660 + (i % 50) * 0.0002 + PolygonState( + points = + listOf( + GeoPoint(lat, lng), + GeoPoint(lat, lng + 0.00016), + GeoPoint(lat + 0.00016, lng + 0.00016), + GeoPoint(lat + 0.00016, lng), + ), + id = "overlap-$i", + fillColor = Color(0xCC7C3AED), + strokeColor = Color(0xFF4C1D95), + strokeWidth = 1.dp, + ) + } + } + Polygons(polygons) + Circle( + center = GeoPoint(42.3550, -71.0610), + radiusMeters = 250.0, + id = "overlap-circle", + fillColor = Color(0xCC0EA5E9), + strokeColor = Color(0xFF0369A1), + ) + Marker(position = GeoPoint(42.3550, -71.0610), id = "overlap-marker") +} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsViewModel.kt new file mode 100644 index 00000000..77b9fcca --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsViewModel.kt @@ -0,0 +1,241 @@ +package com.mapconductor.example.pages.polyline.boston + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewStateInterface +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions +import com.mapconductor.example.pages.polygon.boston.LoadPhase +import android.content.Context +import android.os.SystemClock +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val LOG_TAG = "BostonRoads" +private val TAPPED = Color(0xFF2563EB) + +/** What the last tap hit: a road, or nothing (`road` null), and the point on the road nearest the tap. */ +data class RoadTap( + val seq: Int, + val road: Road?, + val point: GeoPoint?, +) + +data class RoadStats( + val roads: Int, + val points: Int, + val tiled: Boolean, + val loadMillis: Long, + val buildMillis: Long, + /** From handing the list to Polylines() until two frames later; null until then. */ + val frameMillis: Long? = null, + /** From handing the list to Polylines() until the map had all of it (every onAdded); null until then. */ + val addedMillis: Long? = null, +) + +/** + * Every road of Boston (Overture Maps, 54,645 polylines) drawn on the real map + * with one Polylines() call: the polyline counterpart of the Boston buildings + * page. Past the tiling threshold the SDK draws them as raster tiles; the + * "Tiles" switch turns that off for this map ([PolylineTilingOptions.Disabled]) + * so the two can be compared, and reloads the roads so the time from handing + * them over to the last onAdded is measured again. + * + * Roads are coloured, widened and stacked by class ([RoadClass]). Tapping a road + * highlights it (an in-place change: only the tiles it is in are drawn again) + * and shows its class, name and length in an InfoBubble at the point on the road + * nearest the tap; tapping elsewhere closes it. Taps, loads and the time to the + * last onAdded are logged under `BostonRoads`. + * + * "Overlap check" adds a tiled set of polygons, a circle and a marker over the + * roads downtown, to see the stacking: polygon tiles < circles < polyline tiles + * < markers. + * + * `camera` ("lat,lng,zoom") and `tiling` ("on" / "off") come from the intent + * extras, so a measurement opens the same view on every provider. + */ +class BostonRoadsViewModel( + private val context: Context, + initCamera: String?, + initTiling: Boolean?, + initOverlap: Boolean, +) : ViewModel() { + private val _mapViewState = MutableStateFlow?>(null) + val mapViewState: StateFlow?> = _mapViewState.asStateFlow() + + val initCameraPosition: MapCameraPosition = + initCamera + ?.split(',') + ?.mapNotNull { it.trim().toDoubleOrNull() } + ?.takeIf { it.size == 3 } + ?.let { (lat, lng, zoom) -> MapCameraPosition(position = GeoPoint(lat, lng), zoom = zoom) } + ?: MapCameraPosition(position = GeoPoint(42.3555, -71.0605), zoom = 14.0) + + private val _tiling = MutableStateFlow(initTiling ?: true) + + /** Whether this map may draw the roads as tiles (else every road is a native polyline). */ + val tiling: StateFlow = _tiling.asStateFlow() + + private val _overlap = MutableStateFlow(initOverlap) + val overlap: StateFlow = _overlap.asStateFlow() + + private val _loadError = MutableStateFlow(null) + val loadError: StateFlow = _loadError.asStateFlow() + + private val _polylineStates = MutableStateFlow>(emptyList()) + val polylineStates: StateFlow> = _polylineStates.asStateFlow() + + private val _stats = MutableStateFlow(null) + val stats: StateFlow = _stats.asStateFlow() + + private val _tap = MutableStateFlow(null) + val tap: StateFlow = _tap.asStateFlow() + + /** Non-null while the roads are on their way; drives the loading overlay. */ + private val _progress = MutableStateFlow(null) + val progress: StateFlow = _progress.asStateFlow() + + private var shownAt = 0L + + /** Which load the roads' onAdded belongs to; a callback from an earlier load is ignored. */ + private var generation = 0 + + /** The ids of this load that have reached the map, from their states' onAdded. */ + private var addedIds = HashSet() + private var listSize = 0 + private var waitingForAdds = false + private var highlighted: Pair? = null + + init { + load() + } + + /** The options for this map's [com.mapconductor.compose.polyline.PolylineTiling]. */ + fun tilingOptions(tiled: Boolean): PolylineTilingOptions = if (tiled) PolylineTilingOptions.Default else PolylineTilingOptions.Disabled + + fun setTiling(tiled: Boolean) { + if (_tiling.value == tiled) return + _tiling.value = tiled + // The roads leave the map and come back, drawn the other way; timed again. + load() + } + + fun setOverlap(on: Boolean) { + _overlap.value = on + } + + fun onMapViewChanged(state: MapViewStateInterface<*>) { + _mapViewState.value = state + } + + fun onMapClick(point: GeoPointInterface) { + onTap(null, null, point) + } + + private fun load() { + val gen = ++generation + addedIds = HashSet() + listSize = 0 + waitingForAdds = false + _polylineStates.value = emptyList() + _stats.value = null + _loadError.value = null + _tap.value = null + highlighted = null + _progress.value = LoadPhase.Read(0, null) + val tiled = _tiling.value + viewModelScope.launch { + val started = SystemClock.elapsedRealtime() + val roads = + try { + withContext(Dispatchers.IO) { + loadBostonRoads(context) { phase -> _progress.value = phase } + } + } catch (e: Exception) { + _progress.value = null + _loadError.value = e.message + return@launch + } + val loadMillis = SystemClock.elapsedRealtime() - started + _progress.value = LoadPhase.Build + val built = SystemClock.elapsedRealtime() + val onAdded: (PolylineState) -> Unit = { state -> onRoadAdded(gen, state.id) } + val states = withContext(Dispatchers.Default) { roads.map { toState(it, onAdded) } } + if (gen != generation) return@launch + val buildMillis = SystemClock.elapsedRealtime() - built + val points = states.sumOf { it.points.size } + _stats.value = RoadStats(states.size, points, tiled, loadMillis, buildMillis) + Log.i(LOG_TAG, "shown roads=${states.size} points=$points tiled=$tiled load=$loadMillis build=$buildMillis") + listSize = states.size + waitingForAdds = true + _progress.value = LoadPhase.Draw + shownAt = SystemClock.elapsedRealtime() + _polylineStates.value = states + } + } + + // Runs on the main thread, once per road each time the map takes it in. + private fun onRoadAdded( + gen: Int, + id: String, + ) { + if (gen != generation || !addedIds.add(id)) return + if (!waitingForAdds || addedIds.size < listSize) return + waitingForAdds = false + _progress.value = null + val added = SystemClock.elapsedRealtime() - shownAt + _stats.value = _stats.value?.copy(addedMillis = added) + Log.i(LOG_TAG, "added roads=$listSize tiled=${_tiling.value} added=$added") + } + + /** The page calls this two frames after the list reached Polylines(). */ + fun onDrawn(states: List) { + if (states.isEmpty() || states !== _polylineStates.value) return + val frame = SystemClock.elapsedRealtime() - shownAt + _stats.value = _stats.value?.copy(frameMillis = frame) + Log.i(LOG_TAG, "drawn roads=${states.size} tiled=${_tiling.value} frame=$frame") + } + + // Moves the highlight to `state` (or clears it) and records the tap. + private fun onTap( + state: PolylineState?, + road: Road?, + point: GeoPointInterface?, + ) { + highlighted?.let { (previous, color) -> previous.strokeColor = color } + highlighted = state?.let { it to it.strokeColor } + state?.strokeColor = TAPPED + val seq = (_tap.value?.seq ?: 0) + 1 + _tap.value = RoadTap(seq, road, point?.let { GeoPoint(it.latitude, it.longitude) }) + Log.i( + LOG_TAG, + "tap seq=$seq id=${road?.id ?: ""} class=${road?.roadClass ?: ""} " + + "lat=${point?.latitude ?: ""} lng=${point?.longitude ?: ""}", + ) + } + + private fun toState( + road: Road, + onAdded: (PolylineState) -> Unit, + ): PolylineState = + PolylineState( + points = road.points, + id = road.id, + strokeColor = road.roadClass.color, + strokeWidth = road.roadClass.width.dp, + zIndex = road.roadClass.zIndex, + extra = road.id, + onClick = { event -> onTap(event.state, road, event.clicked) }, + onAdded = onAdded, + ) +} diff --git a/example-app/src/test/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsDataTest.kt b/example-app/src/test/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsDataTest.kt new file mode 100644 index 00000000..3c2d3aa6 --- /dev/null +++ b/example-app/src/test/java/com/mapconductor/example/pages/polyline/boston/BostonRoadsDataTest.kt @@ -0,0 +1,80 @@ +package com.mapconductor.example.pages.polyline.boston + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class BostonRoadsDataTest { + /** + * Two roads (fields name | road class | lines = 56, 1e-5 degree), written by + * the generator (the React sample's tools/boston/fixture.py). The same bytes + * are decoded by the React and iOS tests. + */ + private val fixture = + hex( + "4d4350470200000002000000380510436166c3a92053747265657420e29895010103b284850483b8e30614140a2800060102" + + "ef56973e1313", + ) + + private fun hex(text: String) = text.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun `decodes the name, the class and the open path of each road`() { + val roads = decodeRoads(fixture) + assertEquals(2, roads.size) + + val first = roads[0] + assertEquals("road-0", first.id) + assertEquals("Café Street ☕", first.name) + assertEquals(RoadClass.PRIMARY, first.roadClass) + // Three points, left open: no closing point added. + assertArrayEquals( + doubleArrayOf(-71.0605, 42.35545, -71.0604, 42.35555, -71.0602, 42.3556), + first.path, + 1e-9, + ) + + val second = roads[1] + assertEquals("road-1", second.id) + assertNull(second.name) + assertEquals(RoadClass.PATH, second.roadClass) + assertArrayEquals(doubleArrayOf(-71.1, 42.3, -71.1001, 42.2999), second.path, 1e-9) + } + + @Test + fun `the bundled roads file has every road of the city`() { + val bytes = File("src/main/assets/boston/boston-city-roads.bin").readBytes() + assertEquals(5, bytes[13].toInt()) + val roads = decodeRoads(bytes) + assertEquals(54645, roads.size) + assertEquals(234765, roads.sumOf { it.path.size / 2 }) + assertEquals( + mapOf( + RoadClass.HIGHWAY to 911, + RoadClass.PRIMARY to 2320, + RoadClass.SECONDARY to 2829, + RoadClass.TERTIARY to 1229, + RoadClass.LOCAL to 11226, + RoadClass.SERVICE to 9950, + RoadClass.PATH to 26180, + ), + roads.groupingBy { it.roadClass }.eachCount(), + ) + assertEquals(18838, roads.count { it.name != null }) + assertTrue(roads.all { it.path.size >= 4 }) + val first = roads.first() + assertEquals("Saint Vincent Section", first.name) + assertEquals(RoadClass.SERVICE, first.roadClass) + assertArrayEquals(doubleArrayOf(-71.1858, 42.28788, -71.1858, 42.28794), first.path.copyOfRange(0, 4), 1e-9) + } + + @Test + fun `length is the geodesic length of the points`() { + // About 13.6 m north-east, then 17.6 m east. + val road = decodeRoads(fixture)[0] + assertEquals(31.0, lengthMetres(road), 1.5) + } +}