mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
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:
@@ -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>,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ class BaseMapViewModelTest {
|
||||
nodeRepository = nodeRepository,
|
||||
packetRepository = packetRepository,
|
||||
radioController = radioController,
|
||||
ioDispatcher = testDispatcher,
|
||||
)
|
||||
|
||||
private fun nodeWithPosition(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user