refactor(map): DRY constants, shared bounding box, i18n fix, CI test fixes

- 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+)
This commit is contained in:
James Rich
2026-04-13 14:45:50 -05:00
parent 22d46a50ef
commit 4be30d229f
15 changed files with 141 additions and 84 deletions

View File

@@ -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<Int>,
val nodesForMarkers: List<Node>,
val nodeLookup: Map<Int, Node>,
)
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
fun BaseMapViewModel.tracerouteNodeSelection(
internal fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
@@ -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<Int, Position>,
nodes: List<Node>,

View File

@@ -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<Int?>(savedStateHandle.get<Int?>("waypointId"))

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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<Int, org.meshtastic.proto.Position>,
nodes: Map<Int, Node>,
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)
},
)
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<Int>("destNum")
private val manualDestNum = MutableStateFlow<Int?>(null)
private val destNum = savedStateHandle.get<Int>("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<List<Position>> =
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 <T> List<T>.filterConsecutiveDuplicates(predicate: (old: T, new: T) -> Boolean): List<T> {
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])
}
}
}
}

View File

@@ -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<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
internal fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
val features =
nodes.mapNotNull { node ->
val pos = node.validPosition ?: return@mapNotNull null
@@ -51,8 +51,8 @@ fun nodesToFeatureCollection(nodes: List<Node>, 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<Node>, myNodeNum: Int? = null): Feature
}
/** Convert waypoints to a GeoJSON [FeatureCollection]. */
fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollection<Point, JsonObject> {
internal fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollection<Point, JsonObject> {
val features =
waypoints.values.mapNotNull { packet ->
val waypoint = packet.waypoint ?: return@mapNotNull null
@@ -87,7 +87,9 @@ fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollec
}
/** Convert position history to a GeoJSON [LineString] for track rendering. */
fun positionsToLineString(positions: List<org.meshtastic.proto.Position>): FeatureCollection<LineString, JsonObject> {
internal fun positionsToLineString(
positions: List<org.meshtastic.proto.Position>,
): FeatureCollection<LineString, JsonObject> {
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<org.meshtastic.proto.Position>): Featu
}
/** Convert position history to individual point features with time metadata. */
fun positionsToPointFeatures(positions: List<org.meshtastic.proto.Position>): FeatureCollection<Point, JsonObject> {
internal fun positionsToPointFeatures(
positions: List<org.meshtastic.proto.Position>,
): FeatureCollection<Point, JsonObject> {
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<org.meshtastic.proto.Position>): 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 <G : Geometry, P> typedFeatureCollection(features: List<Feature<G, P>>): FeatureCollection<G, P> =
FeatureCollection(features) as FeatureCollection<G, P>
FeatureCollection(features)
private const val BMP_MAX = 0xFFFF
private const val SUPPLEMENTARY_OFFSET = 0x10000

View File

@@ -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<GeoPosition>): 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()),
)
}

View File

@@ -76,6 +76,7 @@ class BaseMapViewModelTest {
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
ioDispatcher = testDispatcher,
)
private fun nodeWithPosition(

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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)
}
}