From 4be30d229f42474fdee0d128667fa58cea5d31fe Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 14:45:50 -0500 Subject: [PATCH] refactor(map): DRY constants, shared bounding box, i18n fix, CI test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract NODE_MARKER_RADIUS, MARKER_STROKE_WIDTH, PRECISION_CIRCLE_STROKE_ALPHA to MapConstants.kt — eliminates duplicates across MaplibreMapContent, InlineMap, and TracerouteLayers - Extract computeBoundingBox() utility — deduplicates identical code in NodeTrackMap and TracerouteMap - Replace hardcoded "Unknown" in TracerouteLayers with stringResource(Res.string.unknown) - Add ioDispatcher constructor parameter to BaseMapViewModel/MapViewModel — tests pass testDispatcher directly, eliminating flaky delay(100) race conditions - Remove dead manualDestNum flow from NodeMapViewModel, simplify destNumFlow - Tighten visibility: TracerouteNodeSelection, GeoJsonConverters, MapConstants, MapLayerItem/LayerType → internal - Remove redundant elvis operators on non-null proto fields (build warnings) - Fix assert() → assertTrue() in MapStyleTest for Kotlin/Native compatibility - Remove unnecessary !! assertions in GeoJsonConvertersTest - Add computeBoundingBox tests (null for <2 positions, correct bounds for 3+) --- .../feature/map/BaseMapViewModel.kt | 10 +++--- .../meshtastic/feature/map/MapViewModel.kt | 5 ++- .../feature/map/component/InlineMap.kt | 10 +++--- .../map/component/MaplibreMapContent.kt | 10 +++--- .../feature/map/component/NodeTrackMap.kt | 13 ++----- .../feature/map/component/TracerouteLayers.kt | 19 ++++++++--- .../feature/map/component/TracerouteMap.kt | 13 ++----- .../meshtastic/feature/map/model/MapLayer.kt | 4 +-- .../feature/map/node/NodeMapViewModel.kt | 34 ++++++++++++------- .../feature/map/util/GeoJsonConverters.kt | 29 +++++++++------- .../feature/map/util/MapConstants.kt | 31 +++++++++++++++-- .../feature/map/BaseMapViewModelTest.kt | 1 + .../feature/map/MapViewModelTest.kt | 11 +----- .../feature/map/model/MapStyleTest.kt | 3 +- .../feature/map/util/GeoJsonConvertersTest.kt | 32 ++++++++++++++--- 15 files changed, 141 insertions(+), 84 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 8bd196a77..89872dc3c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import org.jetbrains.compose.resources.StringResource -import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node @@ -43,6 +43,7 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint +import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher /** * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute @@ -54,6 +55,7 @@ open class BaseMapViewModel( protected val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, private val radioController: RadioController, + private val ioDispatcher: CoroutineDispatcher = defaultIoDispatcher, ) : ViewModel() { val myNodeInfo = nodeRepository.myNodeInfo @@ -208,14 +210,14 @@ open class BaseMapViewModel( * @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available). * @property nodeLookup Node-num-keyed map for polyline coordinate resolution. */ -data class TracerouteNodeSelection( +internal data class TracerouteNodeSelection( val overlayNodeNums: Set, val nodesForMarkers: List, val nodeLookup: Map, ) /** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */ -fun BaseMapViewModel.tracerouteNodeSelection( +internal fun BaseMapViewModel.tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, @@ -232,7 +234,7 @@ fun BaseMapViewModel.tracerouteNodeSelection( * * @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB. */ -fun tracerouteNodeSelection( +internal fun tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 60e5df0cd..616d953b0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,6 +35,7 @@ import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.meshtastic.proto.Waypoint import org.maplibre.spatialk.geojson.Position as GeoPosition +import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher /** * Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels. @@ -49,7 +51,8 @@ class MapViewModel( packetRepository: PacketRepository, radioController: RadioController, savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { + ioDispatcher: CoroutineDispatcher = defaultIoDispatcher, +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, ioDispatcher) { /** Currently selected waypoint to focus on map. */ private val selectedWaypointIdInternal = MutableStateFlow(savedStateHandle.get("waypointId")) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 977e2bd52..8f555895d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -37,12 +37,14 @@ import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS +import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA import org.meshtastic.feature.map.util.precisionBitsToMeters import org.meshtastic.feature.map.util.toGeoPositionOrNull private const val DEFAULT_ZOOM = 15.0 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f -private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the @@ -74,14 +76,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { CircleLayer( id = "inline-node-marker", source = source, - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = const(Color(node.colors.second)), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), ) // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp - val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) + val precisionMeters = precisionBitsToMeters(position.precision_bits) val metersPerDp = cameraState.metersPerDpAtTarget if (precisionMeters > 0 && metersPerDp > 0) { val radiusDp = (precisionMeters / metersPerDp).dp diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 19871567d..2f23615e6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -61,6 +61,9 @@ import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS +import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA import org.meshtastic.feature.map.util.nodesToFeatureCollection import org.meshtastic.feature.map.util.waypointsToFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -69,7 +72,6 @@ private val NodeMarkerColor = Color(0xFF6750A4) private const val CLUSTER_RADIUS = 50 private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f -private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * Ground resolution at the equator: meters per pixel = 156543.03 / 2^zoom. We use an exponential(2) interpolation with @@ -198,7 +200,7 @@ private fun NodeMarkerLayers( radius = const(20.dp), color = const(NodeMarkerColor), // Material primary opacity = const(CLUSTER_OPACITY), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), onClick = { features -> val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass @@ -230,9 +232,9 @@ private fun NodeMarkerLayers( id = "node-markers", source = nodesSource, filter = !feature.has("cluster"), - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = feature["background_color"].convertToColor(const(NodeMarkerColor)), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), onClick = { features -> val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 6f8f34d68..78091b1fc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -28,8 +28,8 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions -import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position @@ -56,16 +56,7 @@ fun NodeTrackMap( val center = remember(geoPositions) { geoPositions.firstOrNull() } - val boundingBox = - remember(geoPositions) { - if (geoPositions.size < 2) return@remember null - val lats = geoPositions.map { it.latitude } - val lngs = geoPositions.map { it.longitude } - BoundingBox( - southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), - northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), - ) - } + val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) } val cameraState = rememberCameraState( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 58895e624..9aa2826dc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.em import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import org.jetbrains.compose.resources.stringResource import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.feature @@ -42,6 +43,10 @@ import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.unknown +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.feature.map.util.typedFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -65,8 +70,13 @@ internal fun TracerouteLayers( ) { if (overlay == null) return + val unknownNodeName = stringResource(Res.string.unknown) + // Build route line features - val routeData = remember(overlay, nodePositions, nodes) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + val routeData = + remember(overlay, nodePositions, nodes, unknownNodeName) { + buildTracerouteGeoJson(overlay, nodePositions, nodes, unknownNodeName) + } // Report mappable count via side effect (avoid state updates during composition) val mappableCount = routeData.hopFeatures.features.size @@ -108,9 +118,9 @@ internal fun TracerouteLayers( CircleLayer( id = "traceroute-hops", source = hopsSource, - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = const(HopMarkerColor), // Purple - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), ) SymbolLayer( @@ -134,6 +144,7 @@ private fun buildTracerouteGeoJson( overlay: TracerouteOverlay, nodePositions: Map, nodes: Map, + unknownNodeName: String, ): TracerouteGeoJsonData { fun nodeToGeoPosition(nodeNum: Int): GeoPosition? { val pos = nodePositions[nodeNum] ?: return null @@ -181,7 +192,7 @@ private fun buildTracerouteGeoJson( buildJsonObject { put("node_num", nodeNum) put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX)) - put("long_name", node?.user?.long_name ?: "Unknown") + put("long_name", node?.user?.long_name ?: unknownNodeName) }, ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 042ad8365..68a93726d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -28,10 +28,10 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions -import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position @@ -64,16 +64,7 @@ fun TracerouteMap( val center = remember(geoPositions) { geoPositions.firstOrNull() } - val boundingBox = - remember(geoPositions) { - if (geoPositions.size < 2) return@remember null - val lats = geoPositions.map { it.latitude } - val lngs = geoPositions.map { it.longitude } - BoundingBox( - southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), - northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), - ) - } + val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) } val cameraState = rememberCameraState( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt index 1d26cff85..194aff629 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt @@ -19,13 +19,13 @@ package org.meshtastic.feature.map.model import kotlin.uuid.Uuid /** Supported custom overlay layer formats. */ -enum class LayerType { +internal enum class LayerType { KML, GEOJSON, } /** A user-importable map overlay layer (KML or GeoJSON file). */ -data class MapLayerItem( +internal data class MapLayerItem( val id: String = Uuid.random().toString(), val name: String, val uriString: String? = null, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index f90b6bb9d..cd1f74389 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt @@ -20,13 +20,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.toList import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.MeshLog import org.meshtastic.core.repository.MeshLogRepository @@ -42,11 +39,9 @@ class NodeMapViewModel( nodeRepository: NodeRepository, meshLogRepository: MeshLogRepository, ) : ViewModel() { - private val destNumFromRoute = savedStateHandle.get("destNum") - private val manualDestNum = MutableStateFlow(null) + private val destNum = savedStateHandle.get("destNum") ?: 0 - private val destNumFlow = - combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 } + private val destNumFlow = MutableStateFlow(destNum) val node = destNumFlow @@ -57,21 +52,34 @@ class NodeMapViewModel( private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged() val positionLogs: StateFlow> = - combine(ourNodeNumFlow, destNumFlow) { ourNodeNum, destNum -> - if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum - } + ourNodeNumFlow + .map { ourNodeNum -> if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum } .distinctUntilChanged() .flatMapLatest { logId -> meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets -> packets .mapNotNull { it.toPosition() } - .asFlow() - .distinctUntilChanged { old, new -> + .filterConsecutiveDuplicates { old, new -> old.time == new.time || (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) } - .toList() } } .stateInWhileSubscribed(initialValue = emptyList()) } + +/** + * Filters consecutive duplicate elements from a list, similar to [Sequence.distinctUntilChanged]. An element is + * considered a duplicate if [predicate] returns `true` for it and the previous element. + */ +private fun List.filterConsecutiveDuplicates(predicate: (old: T, new: T) -> Boolean): List { + if (size <= 1) return this + return buildList { + add(this@filterConsecutiveDuplicates.first()) + for (i in 1 until this@filterConsecutiveDuplicates.size) { + if (!predicate(this@filterConsecutiveDuplicates[i - 1], this@filterConsecutiveDuplicates[i])) { + add(this@filterConsecutiveDuplicates[i]) + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index 9bf687512..e3ea84423 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -31,7 +31,7 @@ private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 /** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */ -fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { +internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null @@ -51,8 +51,8 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature put("rssi", node.rssi) put("foreground_color", intToHexColor(colors.first)) put("background_color", intToHexColor(colors.second)) - put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS) - put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) + put("has_precision", pos.precision_bits in MIN_PRECISION_BITS..MAX_PRECISION_BITS) + put("precision_meters", precisionBitsToMeters(pos.precision_bits)) } Feature(geometry = Point(geoPos), properties = props) @@ -62,7 +62,7 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature } /** Convert waypoints to a GeoJSON [FeatureCollection]. */ -fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { +internal fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { val features = waypoints.values.mapNotNull { packet -> val waypoint = packet.waypoint ?: return@mapNotNull null @@ -87,7 +87,9 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec } /** Convert position history to a GeoJSON [LineString] for track rendering. */ -fun positionsToLineString(positions: List): FeatureCollection { +internal fun positionsToLineString( + positions: List, +): FeatureCollection { val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } if (coords.size < 2) return FeatureCollection(emptyList()) @@ -100,16 +102,18 @@ fun positionsToLineString(positions: List): Featu } /** Convert position history to individual point features with time metadata. */ -fun positionsToPointFeatures(positions: List): FeatureCollection { +internal fun positionsToPointFeatures( + positions: List, +): FeatureCollection { val features = positions.mapNotNull { pos -> val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val props = buildJsonObject { - put("time", (pos.time ?: 0).toString()) + put("time", pos.time.toString()) put("altitude", pos.altitude ?: 0) put("ground_speed", pos.ground_speed ?: 0) - put("sats_in_view", pos.sats_in_view ?: 0) + put("sats_in_view", pos.sats_in_view) } Feature(geometry = Point(geoPos), properties = props) @@ -120,7 +124,7 @@ fun positionsToPointFeatures(positions: List): Fe /** Approximate meters of positional uncertainty from precision_bits (10-19). */ @Suppress("MagicNumber") -fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { +internal fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { 10 -> 5886.0 11 -> 2944.0 12 -> 1472.0 @@ -137,12 +141,11 @@ fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog /** - * Wraps [FeatureCollection] constructor with an unchecked cast to the desired type parameters. Centralizes the single - * unavoidable cast required by the spatialk GeoJSON API. + * Wraps [FeatureCollection] constructor with the desired type parameters. Centralizes the typed constructor call + * required by the spatialk GeoJSON API. */ -@Suppress("UNCHECKED_CAST") internal fun typedFeatureCollection(features: List>): FeatureCollection = - FeatureCollection(features) as FeatureCollection + FeatureCollection(features) private const val BMP_MAX = 0xFFFF private const val SUPPLEMENTARY_OFFSET = 0x10000 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt index 59325300d..df78d4b3c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -16,17 +16,44 @@ */ package org.meshtastic.feature.map.util +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.maplibre.spatialk.geojson.BoundingBox import org.maplibre.spatialk.geojson.Position as GeoPosition /** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ -const val COORDINATE_SCALE = 1e-7 +internal const val COORDINATE_SCALE = 1e-7 + +/** Standard radius for node and hop marker circles across all map composables. */ +internal val NODE_MARKER_RADIUS: Dp = 8.dp + +/** Standard stroke width for marker circle outlines across all map composables. */ +internal val MARKER_STROKE_WIDTH: Dp = 2.dp + +/** Opacity for precision circle strokes (shared between main map and inline map). */ +internal const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and * longitude are zero (indicating no valid position). */ -fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { +internal fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { val lat = (latI ?: 0) * COORDINATE_SCALE val lng = (lngI ?: 0) * COORDINATE_SCALE return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) } + +/** + * Compute a [BoundingBox] that encloses all [positions], or `null` if fewer than 2 positions are provided. Used by + * [NodeTrackMap][org.meshtastic.feature.map.component.NodeTrackMap] and + * [TracerouteMap][org.meshtastic.feature.map.component.TracerouteMap] to fit the camera to track/route bounds. + */ +internal fun computeBoundingBox(positions: List): BoundingBox? { + if (positions.size < 2) return null + val lats = positions.map { it.latitude } + val lngs = positions.map { it.longitude } + return BoundingBox( + southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), + northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + ) +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index d9d0629aa..a6f698181 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -76,6 +76,7 @@ class BaseMapViewModelTest { nodeRepository = nodeRepository, packetRepository = packetRepository, radioController = radioController, + ioDispatcher = testDispatcher, ) private fun nodeWithPosition( diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index a875d9e2a..12321cc52 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -23,7 +23,6 @@ import dev.mokkery.every import dev.mokkery.mock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -78,6 +77,7 @@ class MapViewModelTest { packetRepository = packetRepository, radioController = radioController, savedStateHandle = savedStateHandle, + ioDispatcher = testDispatcher, ) @Test @@ -198,9 +198,6 @@ class MapViewModelTest { position = position, ) - // sendWaypoint dispatches to ioDispatcher; give it time to execute - delay(100) - // FakeRadioController.getPacketId() returns 1, and sendMessage appends to sentPackets assertEquals(1, radioController.sentPackets.size) val sent = radioController.sentPackets.first() @@ -229,8 +226,6 @@ class MapViewModelTest { position = null, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) val wpt = radioController.sentPackets.first().waypoint!! assertEquals(42, wpt.id) // Retains existing ID @@ -256,8 +251,6 @@ class MapViewModelTest { position = position, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) assertEquals(99, radioController.sentPackets.first().waypoint!!.locked_to) } @@ -274,8 +267,6 @@ class MapViewModelTest { position = null, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) val wpt = radioController.sentPackets.first().waypoint!! assertEquals(0, wpt.latitude_i) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt index dc36f1c9a..e63009765 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt @@ -20,6 +20,7 @@ import org.maplibre.compose.style.BaseStyle import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class MapStyleTest { @@ -35,7 +36,7 @@ class MapStyleTest { @Test fun allStyles_haveNonBlankUri() { for (style in MapStyle.entries) { - assert(style.styleUri.isNotBlank()) { "${style.name} has a blank styleUri" } + assertTrue(style.styleUri.isNotBlank(), "${style.name} has a blank styleUri") } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt index df1de8fe5..1b5502f80 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -76,7 +76,7 @@ class GeoJsonConvertersTest { assertEquals(40.0, coords.latitude, 0.001) assertEquals(-74.0, coords.longitude, 0.001) - val props = feature.properties!! + val props = feature.properties assertEquals(42, props["node_num"]?.toString()?.toIntOrNull()) assertEquals("\"AB\"", props["short_name"].toString()) assertEquals("\"Alpha Bravo\"", props["long_name"].toString()) @@ -88,7 +88,7 @@ class GeoJsonConvertersTest { fun nodesToFeatureCollection_isMyNodeFalseForOtherNodes() { val node = Node(num = 10, position = Position(latitude_i = 400000000, longitude_i = -740000000)) val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42) - val props = result.features.first().properties!! + val props = result.features.first().properties assertEquals("false", props["is_my_node"].toString()) } @@ -141,7 +141,7 @@ class GeoJsonConvertersTest { assertEquals(51.5, coords.latitude, 0.001) assertEquals(-0.1, coords.longitude, 0.001) - val props = feature.properties!! + val props = feature.properties assertEquals(99, props["waypoint_id"]?.toString()?.toIntOrNull()) assertEquals("\"Home\"", props["name"].toString()) } @@ -197,7 +197,7 @@ class GeoJsonConvertersTest { val positions = listOf(Position(latitude_i = 400000000, longitude_i = -740000000, time = 1000, altitude = 100)) val result = positionsToPointFeatures(positions) assertEquals(1, result.features.size) - val props = result.features.first().properties!! + val props = result.features.first().properties assertEquals("\"1000\"", props["time"].toString()) assertEquals(100, props["altitude"]?.toString()?.toIntOrNull()) } @@ -329,4 +329,28 @@ class GeoJsonConvertersTest { val result = typedFeatureCollection(features) assertEquals(1, result.features.size) } + + // --- computeBoundingBox --- + + @Test + fun computeBoundingBox_fewerThanTwoPositions_returnsNull() { + assertNull(computeBoundingBox(emptyList())) + assertNull(computeBoundingBox(listOf(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)))) + } + + @Test + fun computeBoundingBox_twoOrMorePositions_returnsBounds() { + val positions = + listOf( + org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0), + org.maplibre.spatialk.geojson.Position(longitude = -73.0, latitude = 41.0), + org.maplibre.spatialk.geojson.Position(longitude = -75.0, latitude = 39.0), + ) + val bbox = computeBoundingBox(positions) + assertNotNull(bbox) + assertEquals(39.0, bbox.southwest.latitude, 0.001) + assertEquals(-75.0, bbox.southwest.longitude, 0.001) + assertEquals(41.0, bbox.northeast.latitude, 0.001) + assertEquals(-73.0, bbox.northeast.longitude, 0.001) + } }