diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 3632ee2f364..7639c750e2f 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -227,6 +227,7 @@ import org.meshtastic.feature.map.layers.MapLayerItem import org.meshtastic.feature.map.layers.opacityOf import org.meshtastic.feature.map.layers.toPickedMapFile import org.meshtastic.feature.map.mergeStationaryRuns +import org.meshtastic.feature.map.stackCoincident import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.tiles.mapAttributionText @@ -243,6 +244,7 @@ import java.io.ByteArrayInputStream import java.io.InputStream import kotlin.coroutines.resume import kotlin.math.abs +import kotlin.math.cos import kotlin.math.max import kotlin.math.roundToInt import android.graphics.Color as AndroidColor @@ -318,6 +320,21 @@ private const val BOX_AUTHORING_MIN_CORNER_DELTA = 1e-4 /** Above the raster basemap at -1, below every marker and shape the mesh draws at 0 and up. */ private const val OVERLAY_Z_INDEX = -0.5f +// A tapped cluster opens to at least this span, about street level, matching the Apple map's floor. +private const val CLUSTER_FIT_MIN_SPAN_METERS = 140.0 +private val CLUSTER_FIT_PADDING = 64.dp +private const val METERS_PER_DEGREE = 111_320.0 + +/** These bounds grown, about their center, to at least [meters] across. */ +private fun LatLngBounds.atLeastSpan(meters: Double): LatLngBounds { + val halfLatitude = meters / 2 / METERS_PER_DEGREE + val halfLongitude = halfLatitude / cos(Math.toRadians(center.latitude)).coerceAtLeast(MIN_COS_LATITUDE) + return including(LatLng(center.latitude + halfLatitude, center.longitude + halfLongitude)) + .including(LatLng(center.latitude - halfLatitude, center.longitude - halfLongitude)) +} + +private const val MIN_COS_LATITUDE = 0.01 + /** Below the offline vector layer's own water/roads/boundaries (-0.75f) so those still draw on top of the shading. */ private const val TERRAIN_HILLSHADE_Z_INDEX = -0.9f @@ -1270,14 +1287,16 @@ fun MapView( showClusterItemsDialog?.let { items -> ClusterMembersDialog( members = - items.map { - ClusterMemberEntry( - nodeNum = it.node.num, - title = it.nodeTitle, - subtitle = it.nodeSnippet, - node = it.node, - ) - }, + items + .flatMap { it.stackMembers.ifEmpty { listOf(it) } } + .map { + ClusterMemberEntry( + nodeNum = it.node.num, + title = it.nodeTitle, + subtitle = it.nodeSnippet, + node = it.node, + ) + }, onMemberClick = { nodeNum -> navigateToNodeDetails(nodeNum) showClusterItemsDialog = null @@ -1324,9 +1343,9 @@ internal fun rememberNodeClusterItems( val unknownText = stringResource(Res.string.unknown) val nowText = stringResource(Res.string.now) return remember(nodes, myNodeNum, relativeTimeBucket, unknownText, nowText) { - nodes.map { node -> + fun itemFor(node: Node): NodeClusterItem { val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) - NodeClusterItem( + return NodeClusterItem( node = node, nodePosition = latLng, nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}", @@ -1334,6 +1353,10 @@ internal fun rememberNodeClusterItems( myNodeNum = myNodeNum, ) } + stackCoincident(nodes, myNodeNum).map { stack -> + val members = if (stack.count > 1) stack.members.map(::itemFor) else emptyList() + itemFor(stack.top).copy(stackMembers = members) + } } } @@ -1359,10 +1382,12 @@ private fun MainMapContent( coroutineScope: CoroutineScope, onShowClusterItemsDialog: (List?) -> Unit, ) { + val clusterFitPaddingPx = with(LocalDensity.current) { CLUSTER_FIT_PADDING.roundToPx() } NodeClusterMarkers( nodeClusterItems = nodeClusterItems, mapFilterState = mapFilterState, navigateToNodeDetails = navigateToNodeDetails, + onStackClick = { onShowClusterItemsDialog(it) }, onClusterClick = { cluster -> val items = cluster.items.toList() val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } @@ -1371,15 +1396,11 @@ private fun MainMapContent( } else { val bounds = LatLngBounds.builder() cluster.items.forEach { bounds.include(it.position) } + // Fit the members, as the Apple map does, rather than step one zoom level and leave a tight cluster + // needing several taps to break. + val fitted = bounds.build().atLeastSpan(CLUSTER_FIT_MIN_SPAN_METERS) coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) + cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(fitted, clusterFitPaddingPx)) } Logger.d { "Cluster clicked! $cluster" } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt index 3952ddf8a6e..e61ad4ad3dd 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -16,9 +16,18 @@ */ package org.meshtastic.app.map.component +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm @@ -30,9 +39,37 @@ import com.google.maps.android.compose.clustering.Clustering import com.google.maps.android.compose.clustering.ClusteringMarkerProperties import org.meshtastic.app.map.model.NodeClusterItem import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.stackBadgeLabel private const val MIN_CLUSTER_SIZE = 10 +// Matches the MapLibre map's precision fill, so both maps shade a degraded position alike. +private const val PRECISION_FILL_ALPHA = 0.15f + +// BadgedBox draws the badge past its anchor's top end corner, and a marker is rasterized at its measured size, so the +// marker is padded by this much above and to the sides to keep the badge unclipped. Never below: the marker anchors on +// its bottom edge, and bottom padding would lift every chip off its point. +private val STACK_BADGE_INSET = 12.dp + +/** A node's chip, with a Material 3 count badge when it stands for several nodes at one spot. */ +@Composable +private fun StackedNodeChip(item: NodeClusterItem, modifier: Modifier = Modifier) { + Box(modifier.padding(start = STACK_BADGE_INSET, top = STACK_BADGE_INSET, end = STACK_BADGE_INSET)) { + BadgedBox( + badge = { + if (item.stackMembers.size > 1) { + // Secondary rather than the default error color: a count of nodes is not an alert. + Badge(containerColor = MaterialTheme.colorScheme.secondary) { + Text(stackBadgeLabel(item.stackMembers.size)) + } + } + }, + ) { + PulsingNodeChip(node = item.node) + } + } +} + /** * Threshold for switching to [NonHierarchicalViewBasedAlgorithm], matching that class's own KDoc describing itself as * being for "large numbers of items (>1000 markers)". @@ -42,10 +79,9 @@ private const val VIEW_BASED_ALGORITHM_NODE_THRESHOLD = 1000 /** * Renders node markers with clustering via the library's [Clustering] composable. * - * Each unclustered node is composed as a [PulsingNodeChip] (`clusterItemContent`), with its z-index forwarded through - * [ClusteringMarkerProperties]; cluster bubbles keep the library's default rendering. Native info windows - * (title/snippet from [NodeClusterItem]) and click interactions are handled by the library renderer, and precision - * circles are drawn for the currently-unclustered items via `clusterItemDecoration`. + * Each unclustered item is composed as a [PulsingNodeChip] (`clusterItemContent`), badged with a count when it stands + * for several nodes at one spot, with its z-index forwarded through [ClusteringMarkerProperties]; cluster bubbles keep + * the library's default rendering. Precision circles are drawn for every item, clustered or not. * * Requires maps-compose >= 8.4.0: earlier versions composed cluster items into a detached `ComposeView` that carried no * lifecycle/saved-state owners, crashing under the Navigation 3 hierarchy (fixed upstream in @@ -65,31 +101,39 @@ fun NodeClusterMarkers( mapFilterState: BaseMapViewModel.MapFilterState, navigateToNodeDetails: (Int) -> Unit, onClusterClick: (Cluster) -> Boolean, + onStackClick: (List) -> Unit, ) { val configuration = LocalConfiguration.current + // One circle per stack, drawn whether or not the stack sits inside a cluster bubble, as the Apple and MapLibre maps + // do: the circle says where the nodes could be, which a count bubble does not. + if (mapFilterState.showPrecisionCircle) { + nodeClusterItems.forEach { item -> + val precisionMeters = item.getPrecisionMeters() + if (precisionMeters != null && precisionMeters > 0) { + key(item.node.num) { + Circle( + center = item.position, + radius = precisionMeters, + fillColor = Color(item.node.colors.second).copy(alpha = PRECISION_FILL_ALPHA), + strokeColor = Color(item.node.colors.second), + strokeWidth = 2f, + zIndex = 0f, + ) + } + } + } + } Clustering( items = nodeClusterItems, onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> navigateToNodeDetails(item.node.num) }, + // A tap goes where the MapLibre map's does: a stack lists its nodes, a single node opens its details. + onClusterItemClick = { item -> + if (item.stackMembers.size > 1) onStackClick(item.stackMembers) else navigateToNodeDetails(item.node.num) + true + }, clusterItemContent = { item -> ClusteringMarkerProperties(zIndex = item.zIndex) - PulsingNodeChip(node = item.node) - }, - clusterItemDecoration = { item -> - if (mapFilterState.showPrecisionCircle) { - item.getPrecisionMeters()?.let { precisionMeters -> - if (precisionMeters > 0) { - Circle( - center = item.position, - radius = precisionMeters, - fillColor = Color(item.node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(item.node.colors.second), - strokeWidth = 2f, - zIndex = 0f, - ) - } - } - } + StackedNodeChip(item) }, onClusterManager = { clusterManager -> // The library renderer extends DefaultClusterRenderer; raise its clustering threshold once. diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt index 49e20f90192..73e0360e1f1 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt @@ -28,6 +28,8 @@ data class NodeClusterItem( val nodeTitle: String, val nodeSnippet: String, val myNodeNum: Int? = null, + /** Every node at this spot, this one included, when more than one is; empty otherwise. */ + val stackMembers: List = emptyList(), ) : ClusterItem { override val position: LatLng get() = nodePosition diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt index f567a21a283..dad989a5ac0 100644 --- a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt @@ -110,6 +110,25 @@ class MapNodeClusterItemsTest { assertNotSame(changedNodeItems, latestItems) } + @Test + fun `nodes at one spot become one item that carries every member`() = runComposeUiTest { + val spot = testNodes(count = 1).single().position + val nodes = testNodes(count = 4).mapIndexed { i, node -> if (i < 3) node.copy(position = spot) else node } + var latestItems: List = emptyList() + + setContent { + val items = rememberNodeClusterItems(nodes = nodes, myNodeNum = 2, relativeTimeBucket = FIXED_TIME_BUCKET) + SideEffect { latestItems = items } + } + waitForIdle() + + assertEquals(2, latestItems.size) + val stack = latestItems.first() + assertEquals(2, stack.node.num) + assertEquals(listOf(1, 2, 3), stack.stackMembers.map { it.node.num }) + assertTrue(latestItems.last().stackMembers.isEmpty()) + } + @Test fun `relative time bucket advances at the next minute boundary`() = runTest { val startSeconds = FIXED_TIME_BUCKET * 60 + 30 @@ -144,8 +163,9 @@ class MapNodeClusterItemsTest { position = Position.Builder() .also { wb -> - wb.latitude_i = 210_000_000 + index - wb.longitude_i = -1_570_000_000 + index + // About 11 m apart, so no two fall within the 5 m that stacks nodes. + wb.latitude_i = 210_000_000 + index * 1_000 + wb.longitude_i = -1_570_000_000 + index * 1_000 wb.time = 1_700_000_000 } .build(), diff --git a/docs/en/user/map-and-waypoints.md b/docs/en/user/map-and-waypoints.md index 573d9d3a516..985d5b17b71 100644 --- a/docs/en/user/map-and-waypoints.md +++ b/docs/en/user/map-and-waypoints.md @@ -2,7 +2,7 @@ title: Map & Waypoints parent: User Guide nav_order: 6 -last_updated: 2026-10-21 +last_updated: 2026-10-24 description: View node positions on the map, create and share waypoints, manage map layers and Site Planner, and control position sharing and privacy. aliases: - map @@ -28,7 +28,9 @@ The map displays: ### Node Markers -Each node that reports a position is shown as a **node chip** marker displaying the node's short name. The chip is colored by the node's own identity color (a stable color derived from its node number) — the same chip used in the node list, so a node looks the same everywhere. Marker color does **not** encode online/offline status. When a node's position updates live, its marker briefly pulses. Nearby markers are clustered as you zoom out. Tapping a cluster zooms in on it, and a cluster whose nodes all share one spot lists them instead. +Each node that reports a position is shown as a **node chip** marker displaying the node's short name. The chip is colored by the node's own identity color (a stable color derived from its node number) — the same chip used in the node list, so a node looks the same everywhere. Marker color does **not** encode online/offline status. When a node's position updates live, its marker briefly pulses. Tapping a chip opens that node's details. + +Nodes within 5 m of each other, which is common when they share a reduced position precision or sit at one site, are drawn as one chip with a count on its corner, showing a favorite or your own node when one is there. Tapping it lists every node at that spot, favorites first. Nearby markers are clustered as you zoom out. Tapping a cluster zooms in to fit its nodes, and a cluster whose nodes all share one spot lists them instead. With **Show Precision Circles** on, each spot's circle stays visible inside a cluster too. ### Map Controls diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapGeometry.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapGeometry.kt index 8681876d610..b89d21c2a2b 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapGeometry.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapGeometry.kt @@ -39,55 +39,16 @@ private fun MapBounds.toBoundingBox(): BoundingBox = BoundingBox( ) /** - * The nodes clustering will leave standing on their own at [zoom]. + * [nodes] ordered by how alone each one is: fewest neighbours within [radiusPx] first. * - * Both the chips and the precision circles are only wanted for these. Clustering decides the split inside the source at - * render time, and there is no API that reports it back, so this applies the same rule supercluster does: a point joins - * a cluster when at least [minPoints] points fall within [radiusPx] of each other in tile pixels. Its own indexes are - * built per whole zoom level, which is why the caller should pass a floored zoom — it also keeps this off the recompose - * path for the fractional zoom of a pinch. + * This is the order to rasterize chips in. Clustering decides inside the source which nodes stand alone and there is no + * API that reports it back, but a node the source draws on its own has, by definition, few neighbours, so the ones that + * need a chip most sort to the front and the ones just past the cluster threshold come next rather than last. That + * makes the image budget run out on nodes buried mid-cluster, which are never drawn anyway. * * Distances are measured in Web Mercator world pixels, the space clustering itself works in, rather than in degrees — a * degree of longitude is a very different distance at the top of the map than at the equator. */ -fun unclusteredNodes(nodes: List, zoom: Int, radiusPx: Int, minPoints: Int, maxClusterZoom: Int): List { - // Past its ceiling the source stops clustering entirely, so everything stands alone. - if (zoom > maxClusterZoom || nodes.size < minPoints) return nodes - - val worldSize = TILE_SIZE * 2.0.pow(zoom) - val pixels = nodes.map { it.worldPixel(worldSize) } - val cells = mutableMapOf, MutableList>() - pixels.forEachIndexed { index, pixel -> cells.getOrPut(pixel.cell(radiusPx)) { mutableListOf() }.add(index) } - - // Supercluster assigns greedily rather than counting neighbours: it walks the points, and the first one to claim a - // neighbourhood takes it. Counting instead called a node clustered whenever enough others were nearby, even where - // those others had already been taken by someone else and the node would in fact be drawn on its own — which left - // it with a bare dot and no chip. - val taken = BooleanArray(nodes.size) - val alone = mutableListOf() - pixels.indices.forEach { index -> - if (taken[index]) return@forEach - val neighbours = neighboursWithin(index, pixels, cells, radiusPx, taken) - if (neighbours.size >= minPoints) { - neighbours.forEach { taken[it] = true } - } else { - // Marked taken too: supercluster excludes every visited point from later neighbourhoods, clustered - // or not — a node already standing alone must not pad a later cluster up to the minimum. - taken[index] = true - alone += nodes[index] - } - } - return alone -} - -/** - * [nodes] ordered by how alone each one is: fewest neighbours within [radiusPx] first. - * - * This is the order to rasterize chips in. A node the source draws on its own has, by definition, few neighbours, so - * the ones that need a chip most sort to the front — and the ones just past the cluster threshold, where - * [unclusteredNodes] is most likely to have guessed wrong, come next rather than last. That makes the image budget run - * out on nodes buried mid-cluster, which are never drawn anyway. - */ fun nodesByIsolation(nodes: List, zoom: Int, radiusPx: Int): List { val worldSize = TILE_SIZE * 2.0.pow(zoom) val pixels = nodes.map { it.worldPixel(worldSize) } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/ClusterMember.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/ClusterMember.kt index a7eea32b7af..a0be69a9113 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/ClusterMember.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/ClusterMember.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.map.maplibre.geojson +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull @@ -29,11 +31,19 @@ data class ClusterMember(val nodeNum: Int, val longName: String, val shortName: /** * Reads the members of a cluster out of its leaf features. * - * The names travel on the features themselves — see [nodesToFeatureCollection] — so listing a cluster needs no second - * lookup against the node database. + * The names travel on the features themselves — see [stacksToFeatureCollection] — so listing a cluster needs no second + * lookup against the node database, and a stack inside a cluster lists every node at its spot. */ -internal fun FeatureCollection<*, *>.toClusterMembers(): List = - features.mapNotNull { feature -> (feature.properties as? JsonObject)?.toClusterMember() } +internal fun FeatureCollection<*, *>.toClusterMembers(): List = features.flatMap { feature -> + (feature.properties as? JsonObject)?.stackMembers().orEmpty() +} + +/** Every node a feature stands for: its stack's members, or the one node when it is not a stack. */ +internal fun JsonObject.stackMembers(): List { + val stacked = this[NodeFeatureKeys.STACK_MEMBERS]?.jsonPrimitive?.contentOrNull + val members = stacked?.let { runCatching { Json.parseToJsonElement(it) as? JsonArray }.getOrNull() } + return members?.mapNotNull { (it as? JsonObject)?.toClusterMember() } ?: listOfNotNull(toClusterMember()) +} private fun JsonObject.toClusterMember(): ClusterMember? { val nodeNum = this[NodeFeatureKeys.NODE_NUM].asInt() ?: return null diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatureKeys.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatureKeys.kt index b04873fbbda..7e20f654a27 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatureKeys.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatureKeys.kt @@ -32,6 +32,12 @@ object NodeFeatureKeys { const val LAST_HEARD = "lastHeard" const val PRECISION_METERS = "precisionMeters" + /** How many nodes share this feature's spot, as a label. Written only when more than one does. */ + const val STACK_COUNT = "stackCount" + + /** The nodes sharing this spot, as a JSON string so a cluster leaf query hands it back intact. */ + const val STACK_MEMBERS = "stackMembers" + /** Which rasterized chip image this node's marker should use. See NodeChipLayer. */ const val CHIP = "chip" } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt index 590141c9563..19a322ecb7c 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map.maplibre.geojson import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.maplibre.spatialk.geojson.Feature @@ -26,6 +27,8 @@ import org.maplibre.spatialk.geojson.Position import org.meshtastic.core.model.Node import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.precisionRadiusMetersOrNull +import org.meshtastic.feature.map.NodeStack +import org.meshtastic.feature.map.stackBadgeLabel /** Formats an `@ColorInt` ARGB value as the `#rrggbb` string MapLibre expressions parse. */ @Suppress("MagicNumber") @@ -41,33 +44,52 @@ internal fun Int.toCssHex(): String { * ocean" jump on the OSMdroid map. */ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection = - FeatureCollection( - nodes.mapNotNull { node -> - node.validPosition ?: return@mapNotNull null - val (foreground, background) = node.colors - Feature( - geometry = Point(Position(longitude = node.longitude, latitude = node.latitude)), - properties = - buildJsonObject { - put(NodeFeatureKeys.NODE_NUM, node.num) - put(NodeFeatureKeys.SHORT_NAME, node.user.short_name) - put(NodeFeatureKeys.LONG_NAME, node.effectiveLongName) - put(NodeFeatureKeys.IS_FAVORITE, node.isFavorite) - put(NodeFeatureKeys.IS_ONLINE, node.isOnline) - put(NodeFeatureKeys.IS_SELF, myNodeNum != null && node.num == myNodeNum) - put(NodeFeatureKeys.FOREGROUND, foreground.toCssHex()) - put(NodeFeatureKeys.BACKGROUND, background.toCssHex()) - put(NodeFeatureKeys.LAST_HEARD, node.lastHeard) - // Omitted rather than written as 0.0 when the node reports no precision: 0 is a real - // radius, and a reader cannot tell the difference. GeoCircle drops such nodes instead. - precisionRadiusMetersOrNull(node.position.precision_bits)?.let { - put(NodeFeatureKeys.PRECISION_METERS, it) - } - put(NodeFeatureKeys.CHIP, node.toNodeChip().featureValue()) - }, - ) + FeatureCollection(nodes.mapNotNull { it.toFeature(myNodeNum) }) + +/** One feature per stack, drawn as its top node and carrying every member for the stack's list. */ +fun stacksToFeatureCollection(stacks: List, myNodeNum: Int? = null): FeatureCollection = + FeatureCollection(stacks.mapNotNull { stack -> stack.top.toFeature(myNodeNum, stack.members) }) + +private fun Node.toFeature(myNodeNum: Int?, stackMembers: List = emptyList()): Feature? { + validPosition ?: return null + val (foreground, background) = colors + return Feature( + geometry = Point(Position(longitude = longitude, latitude = latitude)), + properties = + buildJsonObject { + put(NodeFeatureKeys.NODE_NUM, num) + put(NodeFeatureKeys.SHORT_NAME, user.short_name) + put(NodeFeatureKeys.LONG_NAME, effectiveLongName) + put(NodeFeatureKeys.IS_FAVORITE, isFavorite) + put(NodeFeatureKeys.IS_ONLINE, isOnline) + put(NodeFeatureKeys.IS_SELF, myNodeNum != null && num == myNodeNum) + put(NodeFeatureKeys.FOREGROUND, foreground.toCssHex()) + put(NodeFeatureKeys.BACKGROUND, background.toCssHex()) + put(NodeFeatureKeys.LAST_HEARD, lastHeard) + // Omitted rather than written as 0.0 when the node reports no precision: 0 is a real + // radius, and a reader cannot tell the difference. GeoCircle drops such nodes instead. + precisionRadiusMetersOrNull(position.precision_bits)?.let { put(NodeFeatureKeys.PRECISION_METERS, it) } + put(NodeFeatureKeys.CHIP, toNodeChip().featureValue()) + if (stackMembers.size > 1) { + put(NodeFeatureKeys.STACK_COUNT, stackBadgeLabel(stackMembers.size)) + put(NodeFeatureKeys.STACK_MEMBERS, stackMembers.toMemberJson()) + } }, ) +} + +private fun List.toMemberJson(): String = buildJsonArray { + forEach { member -> + add( + buildJsonObject { + put(NodeFeatureKeys.NODE_NUM, member.num) + put(NodeFeatureKeys.SHORT_NAME, member.user.short_name) + put(NodeFeatureKeys.LONG_NAME, member.effectiveLongName) + }, + ) + } +} + .toString() /** * One distinct chip appearance. Two markers that look identical only need drawing once. diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeChipLayer.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeChipLayer.kt index 055c99bb44d..8d6467f20d2 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeChipLayer.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeChipLayer.kt @@ -64,10 +64,12 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Person import org.meshtastic.core.ui.icon.Temperature import org.meshtastic.feature.map.MapNodePolicy +import org.meshtastic.feature.map.maplibre.geojson.ClusterMember import org.meshtastic.feature.map.maplibre.geojson.MapChipGlyph import org.meshtastic.feature.map.maplibre.geojson.MapChipKey import org.meshtastic.feature.map.maplibre.geojson.NodeFeatureKeys import org.meshtastic.feature.map.maplibre.geojson.featureValue +import org.meshtastic.feature.map.maplibre.geojson.stackMembers import org.meshtastic.feature.map.maplibre.geojson.toNodeChip /** @@ -95,6 +97,7 @@ internal fun NodeChipLayer( nodes: List, onNodeClick: ((Int) -> Unit)? = null, chipFilter: Expression? = null, + onStackClick: ((List) -> Unit)? = null, ) = MapChipLayer( id = id, source = source, @@ -107,14 +110,17 @@ internal fun NodeChipLayer( { features -> // The chip the user sees on top is the one they meant. Query order is not that order, so pick // by the rank [chipSortKey] draws by — on the Google map the highest zIndex marker takes the tap. - features - .maxByOrNull { it.properties.chipRank() } - ?.properties - ?.get(NodeFeatureKeys.NODE_NUM) - ?.let { nodeNum -> + val tapped = features.maxByOrNull { it.properties.chipRank() }?.properties + val stack = tapped?.stackMembers().orEmpty() + if (onStackClick != null && stack.size > 1) { + onStackClick(stack) + ClickResult.Consume + } else { + tapped?.get(NodeFeatureKeys.NODE_NUM)?.let { nodeNum -> click(nodeNum.jsonPrimitive.int) ClickResult.Consume } ?: ClickResult.Pass + } } }, ) diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeLayers.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeLayers.kt index 910c7e3d331..ac35eecc5f5 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeLayers.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/NodeLayers.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive +import org.maplibre.compose.expressions.dsl.and import org.maplibre.compose.expressions.dsl.asNumber import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const @@ -43,17 +44,19 @@ import org.maplibre.spatialk.geojson.Position import org.meshtastic.core.model.Node import org.meshtastic.feature.map.maplibre.geojson.ClusterMember import org.meshtastic.feature.map.maplibre.geojson.NodeFeatureKeys -import org.meshtastic.feature.map.maplibre.geojson.nodesToFeatureCollection import org.meshtastic.feature.map.maplibre.geojson.precisionCirclesToFeatureCollection import org.meshtastic.feature.map.maplibre.geojson.rememberFeatureSource +import org.meshtastic.feature.map.maplibre.geojson.stackMembers +import org.meshtastic.feature.map.maplibre.geojson.stacksToFeatureCollection import org.meshtastic.feature.map.maplibre.geojson.toClusterMembers import org.meshtastic.feature.map.maplibre.nodesByIsolation import org.meshtastic.feature.map.maplibre.nodesInView import org.meshtastic.feature.map.maplibre.style.MapColors -import org.meshtastic.feature.map.maplibre.unclusteredNodes +import org.meshtastic.feature.map.stackBadgeLabel +import org.meshtastic.feature.map.stackCoincident -// GeoJsonSource.NO_EXPANSION_ZOOM says the same thing, but its companion is private. A cluster that cannot be -// expanded answers with a sentinel: 0 on Android and desktop, -1 on iOS. +// GeoJsonSource.NO_EXPANSION_ZOOM says the same thing, but its companion is private. A query that cannot answer +// returns a sentinel: 0 on Android and desktop, -1 on iOS. private const val NO_EXPANSION_ZOOM = 0.0 private const val CLUSTER_LEAF_LIMIT = 100 private const val CLUSTER_LEAF_OFFSET = 0 @@ -98,25 +101,17 @@ fun NodeLayers( visibleBounds: BoundingBox? = null, zoom: Int = 0, ) { - // What clustering will leave standing on its own. Both the precision circles and the chips are only wanted for - // these: a node folded into a cluster is not drawn, so decorating it paints something nobody asked about — at - // event density 2,500 translucent circles stack into one opaque disc over the whole venue. - val unclustered = - remember(nodes, zoom) { - unclusteredNodes( - nodes = nodes, - zoom = zoom, - radiusPx = CLUSTER_RADIUS, - minPoints = CLUSTER_MIN_POINTS, - maxClusterZoom = CLUSTER_MAX_ZOOM, - ) - } + // Nodes at one spot draw as one marker, so the source, the chips and the circles all see a stack's top node. + val stacks = remember(nodes, myNodeNum) { stackCoincident(nodes, myNodeNum) } + val tops = remember(stacks) { stacks.map { it.top } } + if (showPrecisionCircles) { - // Narrowed to the viewport as well, for the reason the chips are: a circle is a 64-vertex polygon, and zoomed - // in far enough that nothing clusters, every node in the mesh is "unclustered" — so the whole mesh was being - // tessellated to draw the handful of circles actually on screen. - val visibleUnclustered = remember(unclustered, visibleBounds) { nodesInView(unclustered, visibleBounds) } - NodePrecisionLayer(id = "node-precision", nodes = visibleUnclustered) + // One circle per stack, drawn whether or not the stack sits inside a cluster bubble, as the Apple map does: the + // circle says where the nodes could be, which a count bubble does not. Only reduced-precision positions have + // one, and a stack already merges every node truncated to the same point, so circles overlap only their + // neighbors. Narrowed to the viewport because each circle is a 64-vertex polygon. + val visibleTops = remember(tops, visibleBounds) { nodesInView(tops, visibleBounds) } + NodePrecisionLayer(id = "node-precision", nodes = visibleTops) } // Every node the source draws on its own needs an image, or it falls through to a bare dot. Which nodes those are @@ -129,7 +124,7 @@ fun NodeLayers( // spatial property, so a node at the edge of the viewport with neighbours just outside it is not in fact alone; // and this keeps the O(n) grid pass keyed on the zoom rather than on the viewport, which changes every frame of // a pan. Narrowing afterwards is an order-preserving filter. - val rankedNodes = remember(nodes, zoom) { nodesByIsolation(nodes, zoom, CLUSTER_RADIUS) } + val rankedNodes = remember(tops, zoom) { nodesByIsolation(tops, zoom, CLUSTER_RADIUS) } val chipNodes = remember(rankedNodes, visibleBounds) { nodesInView(rankedNodes, visibleBounds) } // getClusterExpansionZoom became a suspend function in maplibre-compose 0.15.0 (feature queries @@ -142,7 +137,7 @@ fun NodeLayers( val nodeSource = rememberFeatureSource( - nodes, + stacks, myNodeNum, options = GeoJsonOptions { @@ -152,7 +147,7 @@ fun NodeLayers( clusterMinPoints = CLUSTER_MIN_POINTS }, ) { - nodesToFeatureCollection(nodes, myNodeNum) + stacksToFeatureCollection(stacks, myNodeNum) } CircleLayer( @@ -186,7 +181,7 @@ fun NodeLayers( // nodes sitting on the same spot, so zooming would do nothing — list them instead. // Null is a query that lost a race with a style change; the handle is stale, so stop. val expansionZoom = handle.getClusterExpansionZoom(clusterFeature) ?: return@launch - if (expansionZoom <= NO_EXPANSION_ZOOM) { + if (clusterCannotSplit(expansionZoom)) { val leaves = handle.getClusterLeaves(clusterFeature, CLUSTER_LEAF_LIMIT, CLUSTER_LEAF_OFFSET) ?: return@launch @@ -211,7 +206,7 @@ fun NodeLayers( ) // Under the chips, so a pulse reads as a halo around the node rather than covering it. - NodePulseLayer(nodes = nodes, source = nodeSource) + NodePulseLayer(nodes = tops, source = nodeSource) // Under the chips, and normally invisible behind them: a chip is 64x28dp and covers this entirely. It exists for // the node whose chip image did not fit under NodeChipLayer's ceiling — without it such a node resolves to a @@ -225,10 +220,17 @@ fun NodeLayers( strokeColor = const(Color.White), strokeWidth = const(2.dp), onClick = { features -> - features.firstOrNull()?.properties?.get(NodeFeatureKeys.NODE_NUM)?.let { nodeNum -> - onNodeClick(nodeNum.jsonPrimitive.int) + val tapped = features.firstOrNull()?.properties + val stack = tapped?.stackMembers().orEmpty() + if (stack.size > 1) { + onClusterMembers(stack) ClickResult.Consume - } ?: ClickResult.Pass + } else { + tapped?.get(NodeFeatureKeys.NODE_NUM)?.let { nodeNum -> + onNodeClick(nodeNum.jsonPrimitive.int) + ClickResult.Consume + } ?: ClickResult.Pass + } }, ) @@ -238,9 +240,27 @@ fun NodeLayers( nodes = chipNodes, onNodeClick = onNodeClick, chipFilter = !feature.has("point_count"), + onStackClick = onClusterMembers, + ) + + val badgeLabels = remember(stacks) { stacks.filter { it.count > 1 }.map { stackBadgeLabel(it.count) }.distinct() } + StackBadgeLayer( + id = "node-stack-badge", + source = nodeSource, + labels = badgeLabels, + filter = feature.has(NodeFeatureKeys.STACK_COUNT) and !feature.has("point_count"), ) } +/** + * Whether a tapped cluster should be listed rather than zoomed into. + * + * Supercluster answers `clusterMaxZoom + 1` for points that never split, which is past where the source clusters at + * all, so zooming there changes nothing. A sentinel at or below [NO_EXPANSION_ZOOM] is a query that could not answer. + */ +internal fun clusterCannotSplit(expansionZoom: Double): Boolean = + expansionZoom <= NO_EXPANSION_ZOOM || expansionZoom > CLUSTER_MAX_ZOOM + /** Small enough that a chip drawn over it hides it completely. */ private const val NODE_DOT_RADIUS = 12 diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/StackBadgeLayer.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/StackBadgeLayer.kt new file mode 100644 index 00000000000..f9ac2ae3750 --- /dev/null +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/StackBadgeLayer.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.maplibre.layers + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.ast.Expression +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.case +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.expressions.dsl.switch +import org.maplibre.compose.expressions.value.BooleanValue +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.VectorSource +import org.meshtastic.feature.map.maplibre.geojson.NodeFeatureKeys + +/** + * The count on a chip that stands for several nodes at one spot. + * + * A map layer cannot host a composable, so this rasterizes Material 3's large badge itself: the label style + * [androidx.compose.material3.Badge] uses at its 16dp height, pill shape and 4dp padding, in the secondary color the + * Google map's badge is given. It sits where `BadgedBox` puts one, overlapping the chip's top trailing corner, so both + * maps show the same badge. + */ +@Suppress("SpreadOperator") // switch() only takes its cases as varargs +@Composable +internal fun StackBadgeLayer(id: String, source: VectorSource, labels: List, filter: Expression) { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val style = MaterialTheme.typography.labelSmall + // Secondary rather than the default error color, as on the Google map: a count of nodes is not an alert. + val container = MaterialTheme.colorScheme.secondary + val content = contentColorFor(container) + val images = + remember(labels, measurer, density, style, container, content) { + labels.associateWith { label -> + val layout = measurer.measure(AnnotatedString(label), style.copy(color = content), density = density) + val width = with(density) { maxOf(BADGE_HEIGHT, layout.size.width.toDp() + BADGE_PADDING * 2) } + BadgePainter(layout, container) to DpSize(width, BADGE_HEIGHT) + } + } + if (images.isEmpty()) return + + SymbolLayer( + id = id, + source = source, + filter = filter, + iconImage = + switch( + input = feature[NodeFeatureKeys.STACK_COUNT].asString(), + *images.map { (label, image) -> case(label, image(image.first, image.second)) }.toTypedArray(), + fallback = image(images.values.first().first, images.values.first().second), + ), + iconAllowOverlap = const(true), + iconIgnorePlacement = const(true), + iconTranslate = const(BADGE_OFFSET), + ) +} + +/** A pill in the badge's container color with its label centered in it. */ +private class BadgePainter(private val layout: TextLayoutResult, private val container: Color) : Painter() { + // Never read: the layer always names an explicit size, which is what lets the pill grow with its label. + override val intrinsicSize: Size = Size.Unspecified + + override fun DrawScope.onDraw() { + drawRoundRect(color = container, cornerRadius = CornerRadius(size.height / 2f)) + drawText( + textLayoutResult = layout, + topLeft = Offset((size.width - layout.size.width) / 2f, (size.height - layout.size.height) / 2f), + ) + } +} + +// Material 3's large badge. +private val BADGE_HEIGHT = 16.dp +private val BADGE_PADDING = 4.dp + +// A chip is 64x28dp and centered on its point; this puts the badge's center just inside its top trailing corner. +private val BADGE_OFFSET = DpOffset(28.dp, (-14).dp) diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/UnclusteredNodesTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/UnclusteredNodesTest.kt deleted file mode 100644 index 5a43eba9c6c..00000000000 --- a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/UnclusteredNodesTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.map.maplibre - -import org.maplibre.spatialk.geojson.BoundingBox -import org.meshtastic.core.model.Node -import org.meshtastic.proto.Position -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The rule deciding which nodes are drawn on their own, and so which ones get a chip and a precision circle. - * - * It stands in for a decision the source makes internally at render time and never reports back. - */ -class UnclusteredNodesTest { - - private companion object { - const val RADIUS_PX = 50 - const val MIN_POINTS = 10 - const val MAX_CLUSTER_ZOOM = 20 - } - - private fun node(num: Int, latitude: Double, longitude: Double) = Node( - num = num, - position = - Position.Builder() - .also { wb -> - wb.latitude_i = (latitude * 1e7).toInt() - wb.longitude_i = (longitude * 1e7).toInt() - } - .build(), - ) - - /** [count] nodes on the same spot, which is what a crowded venue looks like. */ - private fun pile(count: Int, latitude: Double = 36.13, longitude: Double = -115.16) = - (1..count).map { node(it, latitude, longitude) } - - private fun unclustered(nodes: List, zoom: Int) = - unclusteredNodes(nodes, zoom, RADIUS_PX, MIN_POINTS, MAX_CLUSTER_ZOOM) - - @Test - fun `a pile bigger than the minimum is clustered so none of it stands alone`() { - assertTrue(unclustered(pile(MIN_POINTS), zoom = 10).isEmpty()) - assertTrue(unclustered(pile(2500), zoom = 10).isEmpty()) - } - - @Test - fun `a pile smaller than the minimum is left alone as the Google flavor leaves it`() { - // Below MIN_CLUSTER_SIZE the Google renderer draws the markers themselves rather than a bubble. - assertEquals(MIN_POINTS - 1, unclustered(pile(MIN_POINTS - 1), zoom = 10).size) - } - - @Test - fun `a node far from the pile stands alone even though the pile clusters`() { - val nodes = pile(50) + node(9999, latitude = 36.60, longitude = -115.60) - - val alone = unclustered(nodes, zoom = 10) - - assertEquals(listOf(9999), alone.map { it.num }) - } - - @Test - fun `zooming in far enough separates a pile that was clustered`() { - // The same points measured in tile pixels: distance grows with zoom, so eventually nobody has enough - // neighbours left inside the radius. - val spread = (1..20).map { node(it, latitude = 36.13 + it * 0.0002, longitude = -115.16) } - - assertTrue(unclustered(spread, zoom = 10).isEmpty()) - assertEquals(spread.size, unclustered(spread, zoom = 19).size) - } - - @Test - fun `above the cluster ceiling nothing is clustered at all`() { - // The source stops clustering past its max zoom, so every node is drawn individually whatever the density. - assertEquals(2500, unclustered(pile(2500), zoom = MAX_CLUSTER_ZOOM + 1).size) - } - - @Test - fun `a node already left alone is not absorbed into a later cluster`() { - // At zoom 10 a degree is ~1456 tile pixels, so 0.0309 deg ~ 45px — inside the 50px radius, one hop at a time. - // The first node's only neighbour is the bridge, so it stands alone and — as supercluster does — leaves the - // pool. The bridge then counts 9 without it, short of MIN_POINTS, so nothing here may cluster: every node - // stands alone rather than the early loner being counted into a cluster that only reaches 10 through it. - val loner = node(1, latitude = 36.13, longitude = -115.2000) - val bridge = node(2, latitude = 36.13, longitude = -115.1691) - val pileNearBridge = (3..10).map { node(it, latitude = 36.13, longitude = -115.1382) } - assertEquals(10, unclustered(listOf(loner, bridge) + pileNearBridge, zoom = 10).size) - } - - @Test - fun `isolation ranking puts the loneliest nodes first so the chip budget reaches them`() { - // The chip budget is spent down this order, so the nodes certain to be drawn on their own have to come first. - val nodes = pile(30) + node(9999, latitude = 36.60, longitude = -115.60) - - val ranked = nodesByIsolation(nodes, zoom = 10, radiusPx = RADIUS_PX) - - assertEquals(9999, ranked.first().num) - assertEquals(nodes.size, ranked.size) - } - - @Test - fun `isolation ranking survives being narrowed to the viewport afterwards`() { - // Ranked over the whole mesh and filtered to the viewport second: a node at the edge of the screen with - // neighbours just off it is not in fact alone, and the filter has to leave that order alone. - // At zoom 10 a degree is roughly 1456 tile pixels, so 0.01 deg sits inside the 50px cluster radius and - // 0.16 deg sits well outside it. - val offScreenPile = pile(30, latitude = 36.13, longitude = -115.16) - val onScreenEdge = node(8888, latitude = 36.13, longitude = -115.15) - val onScreenLoner = node(9999, latitude = 36.13, longitude = -115.00) - - val ranked = nodesByIsolation(offScreenPile + onScreenEdge + onScreenLoner, zoom = 10, radiusPx = RADIUS_PX) - // West edge falls between the pile and the node beside it, so only the latter two are on screen. - val onScreen = BoundingBox(west = -115.155, south = 36.12, east = -114.99, north = 36.14) - - val visible = nodesInView(ranked, onScreen) - - assertEquals(listOf(9999, 8888), visible.map { it.num }) - } -} diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/StackFeaturesTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/StackFeaturesTest.kt new file mode 100644 index 00000000000..134fcb225eb --- /dev/null +++ b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/StackFeaturesTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.maplibre.geojson + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.stackCoincident +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StackFeaturesTest { + + private fun node(num: Int, latitudeI: Int) = Node( + num = num, + position = + Position.Builder() + .also { wb -> + wb.latitude_i = latitudeI + wb.longitude_i = -739508224 + } + .build(), + user = + User.Builder().also { wb -> wb.long_name = "Node $num" }.also { wb -> wb.short_name = "N$num" }.build(), + ) + + private fun features(vararg nodes: Node) = + stacksToFeatureCollection(stackCoincident(nodes.toList(), myNodeNum = null)).features.map { + checkNotNull(it.properties) + } + + private fun JsonObject.text(key: String) = this[key]?.jsonPrimitive?.contentOrNull + + @Test + fun `nodes at one spot become one feature carrying the count and every member`() { + val stacked = features(node(1, 406585344), node(2, 406585344), node(3, 406585344)).single() + + assertEquals("3", stacked.text(NodeFeatureKeys.STACK_COUNT)) + assertEquals( + listOf( + ClusterMember(1, "Node 1", "N1"), + ClusterMember(2, "Node 2", "N2"), + ClusterMember(3, "Node 3", "N3"), + ), + stacked.stackMembers(), + ) + } + + @Test + fun `a node alone at its spot carries no stack and lists only itself`() { + val alone = features(node(1, 406585344), node(2, 407109632)).first() + + assertNull(alone[NodeFeatureKeys.STACK_COUNT]) + assertEquals(listOf(ClusterMember(1, "Node 1", "N1")), alone.stackMembers()) + } +} diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/layers/ClusterCannotSplitTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/layers/ClusterCannotSplitTest.kt new file mode 100644 index 00000000000..de6d37293a4 --- /dev/null +++ b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/layers/ClusterCannotSplitTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.maplibre.layers + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClusterCannotSplitTest { + + @Test + fun `nodes on one spot report an expansion zoom past the cluster ceiling and are listed`() { + assertTrue(clusterCannotSplit(21.0)) + } + + @Test + fun `a cluster that splits at or below the ceiling is zoomed into`() { + assertFalse(clusterCannotSplit(12.0)) + assertFalse(clusterCannotSplit(20.0)) + } + + @Test + fun `a sentinel from a query that could not answer is listed rather than zoomed`() { + assertTrue(clusterCannotSplit(0.0)) + assertTrue(clusterCannotSplit(-1.0)) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/NodeStack.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/NodeStack.kt new file mode 100644 index 00000000000..ceb869bc91e --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/NodeStack.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map + +import org.meshtastic.core.model.Node +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.hypot + +/** + * Nodes closer than this on the ground share one marker, since no zoom level separates them. Matches the Apple client's + * `MapColocation.spreadMeters`. + */ +const val STACK_SPREAD_METERS = 5.0 + +/** + * Groups nodes that sit within [spreadMeters] of each other, so each spot draws as one marker. + * + * Reduced position precision truncates every node in a cell to the same point, and radios at one site report fixes a + * few meters apart; both would otherwise draw as chips piled on one another. Each group is anchored on its first + * member, and groups keep the order their first member had in [nodes]. + */ +fun stackCoincident(nodes: List, myNodeNum: Int?, spreadMeters: Double = STACK_SPREAD_METERS): List { + val onTop = compareBy { MapNodePolicy.priorityOf(it, myNodeNum) }.thenByDescending { it.num } + val groups = mutableListOf>() + // Anchors bucketed into latitude bands spreadMeters tall, so a node is only measured against its own band and the + // two beside it rather than against every group. + val bands = mutableMapOf>() + nodes.forEach { node -> + val band = node.latitudeBand(spreadMeters) + val group = + (band - 1..band + 1) + .flatMap { bands[it].orEmpty() } + .firstOrNull { groups[it].first().metersFrom(node) < spreadMeters } + if (group != null) { + groups[group] += node + } else { + groups += mutableListOf(node) + bands.getOrPut(band) { mutableListOf() } += groups.lastIndex + } + } + return groups.map { members -> NodeStack(top = members.maxWith(onTop), members = members) } +} + +/** + * Nodes at one spot. [top] is the one drawn: this node or a favorite first, then the lowest node number, which stays + * put as packets arrive so a busy spot's chip does not keep changing. + */ +data class NodeStack(val top: Node, val members: List) { + val count: Int + get() = members.size +} + +/** What a stack's badge reads. Material 3 caps a badge's count at four characters. */ +fun stackBadgeLabel(count: Int): String = if (count > MAX_BADGE_COUNT) "$MAX_BADGE_COUNT+" else count.toString() + +private const val MAX_BADGE_COUNT = 999 + +private fun Node.latitudeBand(spreadMeters: Double): Int = floor(latitude * METERS_PER_DEGREE / spreadMeters).toInt() + +/** Equirectangular ground distance, which is exact enough at the few meters this compares. */ +private fun Node.metersFrom(other: Node): Double { + val north = (other.latitude - latitude) * METERS_PER_DEGREE + val east = + (other.longitude - longitude) * METERS_PER_DEGREE * cos((latitude + other.latitude) / 2 * RADIANS_PER_DEGREE) + return hypot(north, east) +} + +private const val METERS_PER_DEGREE = 111_320.0 +private const val RADIANS_PER_DEGREE = PI / 180 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMemberEntry.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMemberEntry.kt index e6622818792..6056cb31408 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMemberEntry.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMemberEntry.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map.component import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName /** * One node in a cluster the map cannot zoom apart. @@ -26,3 +27,36 @@ import org.meshtastic.core.model.Node * database no longer has. When it is present the row gets the same chip the rest of the app draws. */ data class ClusterMemberEntry(val nodeNum: Int, val title: String, val subtitle: String, val node: Node?) + +/** + * The order both maps list a spot's nodes in: favorites first, then by name with runs of digits compared as numbers, so + * S6 comes before S10. + */ +internal fun List.inDialogOrder(): List = sortedWith( + compareByDescending { it.node?.isFavorite == true } + .then { a, b -> naturalCompare(a.sortName(), b.sortName()) } + .thenBy { it.nodeNum }, +) + +// The Google map's title carries a heard-ago suffix, so the long name, where known, is what both maps sort on. +private fun ClusterMemberEntry.sortName(): String = node?.effectiveLongName?.ifBlank { null } ?: title + +// ASCII digits only, in the split and in the test alike: Char.isDigit also accepts other scripts' digits, which the +// split would leave inside a text run, and the two disagreeing breaks the comparator's ordering. +private val nameRuns = Regex("""[0-9]+|[^0-9]+""") + +internal fun naturalCompare(a: String, b: String): Int { + val x = nameRuns.findAll(a.lowercase()).map { it.value }.toList() + val y = nameRuns.findAll(b.lowercase()).map { it.value }.toList() + for (i in 0 until minOf(x.size, y.size)) { + val (p, q) = x[i] to y[i] + val order = + if (p[0] in '0'..'9' && q[0] in '0'..'9') { + compareValuesBy(p.trimStart('0'), q.trimStart('0'), { it.length }, { it }) + } else { + p.compareTo(q) + } + if (order != 0) return order + } + return x.size.compareTo(y.size) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMembersDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMembersDialog.kt index 205b9a93a11..8d6008172ea 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMembersDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/ClusterMembersDialog.kt @@ -58,7 +58,7 @@ fun ClusterMembersDialog( LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) { // Deduped by node number: it is the key and must be unique, and a node can reach the map from two // sources, or arrive as a `num = 0` placeholder. - items(items = members.distinctBy { it.nodeNum }, key = { it.nodeNum }) { member -> + items(items = members.distinctBy { it.nodeNum }.inDialogOrder(), key = { it.nodeNum }) { member -> ClusterMemberRow(member = member, onClick = { onMemberClick(member.nodeNum) }) } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/NodeStackTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/NodeStackTest.kt new file mode 100644 index 00000000000..23837ad80a4 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/NodeStackTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.Position +import kotlin.math.floor +import kotlin.test.Test +import kotlin.test.assertEquals + +class NodeStackTest { + + private fun node(num: Int, latitudeI: Int, longitudeI: Int, lastHeard: Int = 0, isFavorite: Boolean = false) = Node( + num = num, + position = + Position.Builder() + .also { wb -> + wb.latitude_i = latitudeI + wb.longitude_i = longitudeI + } + .build(), + lastHeard = lastHeard, + isFavorite = isFavorite, + ) + + @Test + fun `nodes at identical coordinates share one stack`() { + val stacks = stackCoincident(List(5) { node(it + 1, 406585344, -739508224) }, myNodeNum = null) + + assertEquals(1, stacks.size) + assertEquals(5, stacks.single().count) + } + + @Test + fun `nodes a few meters apart share a stack`() { + // 300 units of 1e-7 degrees is about 3.3 m north, under the 5 m spread. + val stacks = stackCoincident(listOf(node(1, 406585344, -739508224), node(2, 406585644, -739508224)), null) + + assertEquals(listOf(2), stacks.map { it.count }) + } + + @Test + fun `nodes farther apart than the spread stay separate`() { + // 1,000 units is about 11 m, east-west as well as north-south. + val north = listOf(node(1, 406585344, -739508224), node(2, 406586344, -739508224)) + val east = listOf(node(1, 406585344, -739508224), node(2, 406585344, -739506224)) + + assertEquals(listOf(1, 1), stackCoincident(north, null).map { it.count }) + assertEquals(listOf(1, 1), stackCoincident(east, null).map { it.count }) + } + + @Test + fun `a stack spanning two latitude bands still forms`() { + // Bands are 5 m tall. This line starts 2 m south of a band edge and runs 4 m north, across it. + val bandDegrees = STACK_SPREAD_METERS / 111_320 + val edge = floor(40.65 / bandDegrees) * bandDegrees + val start = ((edge - 2.0 / 111_320) * 1e7).toInt() + val nodes = (0 until 10).map { node(it + 1, start + it * 40, -739508224) } + + assertEquals(listOf(10), stackCoincident(nodes, null).map { it.count }) + } + + @Test + fun `badge counts past 999 read as 999 plus`() { + assertEquals("15", stackBadgeLabel(15)) + assertEquals("999", stackBadgeLabel(999)) + assertEquals("999+", stackBadgeLabel(1000)) + } + + @Test + fun `this node is on top of its stack`() { + val nodes = listOf(node(1, 0, 0, lastHeard = 900), node(2, 0, 0, lastHeard = 100), node(3, 0, 0)) + + assertEquals(2, stackCoincident(nodes, myNodeNum = 2).single().top.num) + } + + @Test + fun `a favorite outranks a node heard more recently`() { + val nodes = listOf(node(1, 0, 0, lastHeard = 900), node(2, 0, 0, lastHeard = 100, isFavorite = true)) + + assertEquals(2, stackCoincident(nodes, null).single().top.num) + } + + @Test + fun `among ordinary nodes the lowest number stays on top however recently the others were heard`() { + val nodes = listOf(node(7, 0, 0, lastHeard = 900), node(3, 0, 0, lastHeard = 100)) + + assertEquals(3, stackCoincident(nodes, null).single().top.num) + } + + @Test + fun `stacks keep the order their first member had`() { + val nodes = listOf(node(1, 0, 0), node(2, 100_000, 100_000), node(3, 0, 0)) + + assertEquals( + listOf(listOf(1, 3), listOf(2)), + stackCoincident(nodes, null).map { s -> s.members.map { it.num } }, + ) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/ClusterMemberOrderTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/ClusterMemberOrderTest.kt new file mode 100644 index 00000000000..bfbf162ec4d --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/ClusterMemberOrderTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.component + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.User +import kotlin.math.sign +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClusterMemberOrderTest { + + private fun entry(num: Int, title: String, favorite: Boolean = false, longName: String? = null) = + ClusterMemberEntry( + nodeNum = num, + title = title, + subtitle = "", + node = + longName?.let { + Node( + num = num, + isFavorite = favorite, + user = User.Builder().also { wb -> wb.long_name = it }.build(), + ) + } ?: Node(num = num, isFavorite = favorite), + ) + + @Test + fun `numbers in names sort as numbers`() { + val ordered = listOf(entry(1, "S10"), entry(2, "S6"), entry(3, "s7"), entry(4, "S15")).inDialogOrder() + + assertEquals(listOf("S6", "s7", "S10", "S15"), ordered.map { it.title }) + } + + @Test + fun `a local display name decides the order over the broadcast long name`() { + val renamed = + ClusterMemberEntry( + nodeNum = 1, + title = "", + subtitle = "", + node = + Node( + num = 1, + displayName = "Alpha", + user = User.Builder().also { wb -> wb.long_name = "Zulu" }.build(), + ), + ) + val plain = entry(2, "", longName = "Mike") + + assertEquals(listOf(1, 2), listOf(plain, renamed).inDialogOrder().map { it.nodeNum }) + } + + @Test + fun `favorites come first`() { + val ordered = listOf(entry(1, "Alpha"), entry(2, "Zulu", favorite = true)).inDialogOrder() + + assertEquals(listOf(2, 1), ordered.map { it.nodeNum }) + } + + @Test + fun `the long name decides over a title that carries a heard-ago suffix`() { + val ordered = + listOf( + entry(1, "B 9 hr. ago", longName = "Stack Five 10"), + entry(2, "A 1 hr. ago", longName = "Stack Five 9"), + ) + .inDialogOrder() + + assertEquals(listOf(2, 1), ordered.map { it.nodeNum }) + } + + @Test + fun `leading zeros and long digit runs still compare by value`() { + assertTrue(naturalCompare("N007", "N10") < 0) + assertTrue(naturalCompare("N99999999999999999999", "N100000000000000000000") < 0) + assertEquals(0, naturalCompare("abc", "ABC")) + } + + @Test + fun `digits from other scripts sort as text and keep the order consistent`() { + val names = listOf("N٣", "N12", "N2", "N١٢") + + names.forEach { a -> + names.forEach { b -> assertEquals(-naturalCompare(b, a).sign, naturalCompare(a, b).sign) } + } + assertTrue(naturalCompare("N2", "N12") < 0) + // A run that starts with a non-ASCII digit is text, so it sorts after a number rather than as the number 3. + assertTrue(naturalCompare("٣", "10") > 0) + } +}