feat(map): add feature parity — filters, style selector, waypoint dialog, cluster zoom, bounds fitting, location tracking

Wire remaining map feature gaps identified in the parity audit:

- MapFilterDropdown: favorites, waypoints, precision circle toggles and
  last-heard slider matching the old Google/OSMDroid filter UIs
- MapStyleSelector: dropdown with 5 predefined MapStyle entries
- EditWaypointDialog: create, edit, delete waypoints via long-press or
  marker tap, with icon picker and lock toggle
- Cluster zoom-to-expand: tap a cluster circle to zoom +2 levels
  centered on the cluster position
- Bounds fitting: NodeTrackMap and TracerouteMap compute a BoundingBox
  from all positions and animate the camera to fit on first load
- Location tracking: expect/actual rememberLocationProviderOrNull()
  bridges platform GPS into maplibre-compose LocationPuck with
  LocationTrackingEffect for auto-pan and bearing follow
- Per-node marker colors via data-driven convertToColor() expressions
- Waypoint camera animation on deep-link selection
- Compass click resets bearing to north
This commit is contained in:
James Rich
2026-04-12 19:25:04 -05:00
parent 598cae564e
commit 2aab65bd1b
12 changed files with 750 additions and 24 deletions

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import androidx.compose.runtime.Composable
import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.rememberDefaultLocationProvider
@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import androidx.compose.runtime.Composable
import org.maplibre.compose.location.LocationProvider
/**
* Returns a platform-appropriate [LocationProvider], or `null` if the platform doesn't support location.
* - Android: uses the platform `LocationManager` via `rememberDefaultLocationProvider()`.
* - iOS: uses `CLLocationManager` via `rememberDefaultLocationProvider()`.
* - Desktop/JS: returns `null` (no location hardware).
*/
@Composable expect fun rememberLocationProviderOrNull(): LocationProvider?

View File

@@ -23,16 +23,35 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.MapFilterDropdown
import org.meshtastic.feature.map.component.MapStyleSelector
import org.meshtastic.feature.map.component.MaplibreMapContent
import org.meshtastic.proto.Waypoint
import org.maplibre.spatialk.geojson.Position as GeoPosition
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
private const val COORDINATE_SCALE = 1e-7
private const val WAYPOINT_ZOOM = 15.0
/**
* Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers,
@@ -41,6 +60,7 @@ import org.meshtastic.feature.map.component.MaplibreMapContent
* This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform
* composable.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MapScreen(
onClickNodeChip: (Int) -> Unit,
@@ -55,11 +75,55 @@ fun MapScreen(
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
val selectedMapStyle by viewModel.selectedMapStyle.collectAsStateWithLifecycle()
LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) }
val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition)
var filterMenuExpanded by remember { mutableStateOf(false) }
// Waypoint dialog state
var showWaypointDialog by remember { mutableStateOf(false) }
var longPressPosition by remember { mutableStateOf<GeoPosition?>(null) }
var editingWaypointId by remember { mutableStateOf<Int?>(null) }
val scope = rememberCoroutineScope()
// Location tracking state
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
val locationProvider = rememberLocationProviderOrNull()
val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
val locationAvailable = locationProvider != null
// Animate to waypoint when waypointId is provided (deep-link)
val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle()
LaunchedEffect(selectedWaypointId, waypoints) {
val wpId = selectedWaypointId ?: return@LaunchedEffect
val packet = waypoints[wpId] ?: return@LaunchedEffect
val wpt = packet.waypoint ?: return@LaunchedEffect
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) {
cameraState.animateTo(
CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM),
)
}
}
// Apply favorites and last-heard filters to the node list
val myNum = viewModel.myNodeNum
val filteredNodes =
remember(nodesWithPosition, filterState, myNum) {
nodesWithPosition
.filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum }
.filter { node ->
filterState.lastHeardFilter.seconds == 0L ||
(nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds ||
node.num == myNum
}
}
@Suppress("ViewModelForwarding")
Scaffold(
modifier = modifier,
@@ -77,7 +141,7 @@ fun MapScreen(
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MaplibreMapContent(
nodes = nodesWithPosition,
nodes = filteredNodes,
waypoints = waypoints,
baseStyle = baseStyle,
cameraState = cameraState,
@@ -86,20 +150,95 @@ fun MapScreen(
showPrecisionCircle = filterState.showPrecisionCircle,
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
onMapLongClick = { position ->
// TODO: open waypoint creation dialog at position
longPressPosition = position
editingWaypointId = null
showWaypointDialog = true
},
modifier = Modifier.fillMaxSize(),
onCameraMoved = { position -> viewModel.saveCameraPosition(position) },
onWaypointClick = { wpId ->
editingWaypointId = wpId
longPressPosition = null
showWaypointDialog = true
},
locationState = if (isLocationTrackingEnabled && locationAvailable) locationState else null,
)
// Auto-pan camera when location tracking is enabled
if (locationAvailable) {
LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) {
cameraState.updateFromLocation()
}
}
MapControlsOverlay(
onToggleFilterMenu = {},
onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
bearing = cameraState.position.bearing.toFloat(),
onCompassClick = {},
isLocationTrackingEnabled = false,
onToggleLocationTracking = {},
onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
filterDropdownContent = {
MapFilterDropdown(
expanded = filterMenuExpanded,
onDismissRequest = { filterMenuExpanded = false },
filterState = filterState,
onToggleFavorites = viewModel::toggleOnlyFavorites,
onToggleWaypoints = viewModel::toggleShowWaypointsOnMap,
onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap,
onSetLastHeardFilter = viewModel::setLastHeardFilter,
)
},
mapTypeContent = {
MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle)
},
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled },
)
}
}
// Waypoint creation/edit dialog
if (showWaypointDialog) {
val editingPacket = editingWaypointId?.let { waypoints[it] }
val editingWaypoint = editingPacket?.waypoint
EditWaypointDialog(
onDismiss = {
showWaypointDialog = false
editingWaypointId = null
longPressPosition = null
},
onSend = { name, description, icon, locked, expire ->
val myNodeNum = viewModel.myNodeNum ?: 0
val wpt =
Waypoint(
id = editingWaypoint?.id ?: viewModel.generatePacketId(),
name = name,
description = description,
icon = icon,
locked_to = if (locked) myNodeNum else 0,
latitude_i =
if (editingWaypoint != null) {
editingWaypoint.latitude_i
} else {
longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0
},
longitude_i =
if (editingWaypoint != null) {
editingWaypoint.longitude_i
} else {
longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0
},
expire = expire,
)
viewModel.sendWaypoint(wpt)
},
onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } },
initialName = editingWaypoint?.name ?: "",
initialDescription = editingWaypoint?.description ?: "",
initialIcon = editingWaypoint?.icon ?: 0,
initialLocked = (editingWaypoint?.locked_to ?: 0) != 0,
isEditing = editingWaypoint != null,
position = longPressPosition,
)
}
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.feature.map.util.convertIntToEmoji
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val MAX_NAME_LENGTH = 29
private const val MAX_DESCRIPTION_LENGTH = 99
private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin
/**
* Dialog for creating or editing a waypoint on the map.
*
* Replaces the old Android-specific `EditWaypointDialog` with a fully cross-platform Compose Multiplatform version.
* Date/time picker for expiry is deferred (requires platform-specific pickers or CMP M3 DatePicker availability).
*/
@Suppress("LongParameterList", "LongMethod")
@Composable
fun EditWaypointDialog(
onDismiss: () -> Unit,
onSend: (name: String, description: String, icon: Int, locked: Boolean, expire: Int) -> Unit,
onDelete: (() -> Unit)? = null,
initialName: String = "",
initialDescription: String = "",
initialIcon: Int = DEFAULT_EMOJI,
initialLocked: Boolean = false,
isEditing: Boolean = false,
position: GeoPosition? = null,
) {
var name by remember { mutableStateOf(initialName) }
var description by remember { mutableStateOf(initialDescription) }
var emojiCodepoint by remember { mutableIntStateOf(if (initialIcon != 0) initialIcon else DEFAULT_EMOJI) }
var locked by remember { mutableStateOf(initialLocked) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = if (isEditing) "Edit Waypoint" else "New Waypoint",
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
// Emoji + Name row
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = convertIntToEmoji(emojiCodepoint),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(end = 8.dp),
)
OutlinedTextField(
value = name,
onValueChange = { if (it.length <= MAX_NAME_LENGTH) name = it },
label = { Text(stringResource(Res.string.name)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(8.dp))
// Description
OutlinedTextField(
value = description,
onValueChange = { if (it.length <= MAX_DESCRIPTION_LENGTH) description = it },
label = { Text(stringResource(Res.string.description)) },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
// Lock toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text("Lock to my node", style = MaterialTheme.typography.bodyMedium)
Switch(checked = locked, onCheckedChange = { locked = it })
}
// Position info
if (position != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${position.latitude}, ${position.longitude}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
dismissButton = {
Row {
if (onDelete != null) {
TextButton(
onClick = {
onDelete()
onDismiss()
},
) {
Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.width(8.dp))
}
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
}
},
confirmButton = {
TextButton(
onClick = {
onSend(name, description, emojiCodepoint, locked, 0)
onDismiss()
},
enabled = name.isNotBlank(),
) {
Text(stringResource(Res.string.send))
}
},
)
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
/**
* Dropdown menu for filtering map markers by favorites, waypoints, precision circles, and last-heard time.
*
* Mirrors the old Google/F-Droid `MapFilterDropdown` — checkboxes for boolean toggles and a slider for last-heard time
* filter.
*/
@Composable
internal fun MapFilterDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
filterState: MapFilterState,
onToggleFavorites: () -> Unit,
onToggleWaypoints: () -> Unit,
onTogglePrecisionCircle: () -> Unit,
onSetLastHeardFilter: (LastHeardFilter) -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.only_favorites)) },
onClick = onToggleFavorites,
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = stringResource(Res.string.only_favorites),
)
},
trailingIcon = { Checkbox(checked = filterState.onlyFavorites, onCheckedChange = { onToggleFavorites() }) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = onToggleWaypoints,
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
trailingIcon = { Checkbox(checked = filterState.showWaypoints, onCheckedChange = { onToggleWaypoints() }) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_precision_circle)) },
onClick = onTogglePrecisionCircle,
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},
trailingIcon = {
Checkbox(checked = filterState.showPrecisionCircle, onCheckedChange = { onTogglePrecisionCircle() })
},
)
HorizontalDivider()
LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter)
}
}
@Composable
private fun LastHeardSlider(currentFilter: LastHeardFilter, onSetFilter: (LastHeardFilter) -> Unit) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(currentFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text = stringResource(Res.string.last_heard_filter_label, stringResource(currentFilter.label)),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
onSetFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.selected_map_type
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.map.model.MapStyle
/**
* Map style selector button + dropdown menu. Shows predefined [MapStyle] entries with a checkmark next to the currently
* selected style.
*/
@Composable
internal fun MapStyleSelector(selectedStyle: MapStyle, onSelectStyle: (MapStyle) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
MapStyle.entries.forEach { style ->
DropdownMenuItem(
text = { Text(stringResource(style.label)) },
onClick = {
onSelectStyle(style)
expanded = false
},
trailingIcon =
if (selectedStyle == style) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
}
}
}

View File

@@ -19,25 +19,33 @@ package org.meshtastic.feature.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.convertToColor
import org.maplibre.compose.expressions.dsl.convertToNumber
import org.maplibre.compose.expressions.dsl.dp
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.dsl.offset
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
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.nodesToFeatureCollection
@@ -50,6 +58,8 @@ 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
private const val CLUSTER_OPACITY = 0.85f
private const val LABEL_OFFSET_EM = 1.5f
private const val CLUSTER_ZOOM_INCREMENT = 2.0
/**
* Main map content composable using MapLibre Compose Multiplatform.
@@ -70,6 +80,8 @@ fun MaplibreMapContent(
onMapLongClick: (GeoPosition) -> Unit,
modifier: Modifier = Modifier,
onCameraMoved: (CameraPosition) -> Unit = {},
onWaypointClick: (Int) -> Unit = {},
locationState: UserLocationState? = null,
) {
MaplibreMap(
modifier = modifier,
@@ -86,12 +98,18 @@ fun MaplibreMapContent(
nodes = nodes,
myNodeNum = myNodeNum,
showPrecisionCircle = showPrecisionCircle,
cameraState = cameraState,
onNodeClick = onNodeClick,
)
// --- Waypoint markers ---
if (showWaypoints) {
WaypointMarkerLayers(waypoints = waypoints)
WaypointMarkerLayers(waypoints = waypoints, onWaypointClick = onWaypointClick)
}
// --- User location puck ---
if (locationState != null) {
LocationPuck(idPrefix = "user-location", locationState = locationState, cameraState = cameraState)
}
}
@@ -103,14 +121,17 @@ fun MaplibreMapContent(
}
}
/** Node markers rendered as clustered circles and symbols using GeoJSON source. */
/** Node markers rendered as clustered circles with per-node colors and short name labels. */
@Suppress("LongMethod")
@Composable
private fun NodeMarkerLayers(
nodes: List<Node>,
myNodeNum: Int?,
showPrecisionCircle: Boolean,
cameraState: CameraState,
onNodeClick: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
val nodesSource =
@@ -120,7 +141,7 @@ private fun NodeMarkerLayers(
GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS),
)
// Cluster circles
// Cluster circles — tap to zoom in toward expansion
CircleLayer(
id = "node-clusters",
source = nodesSource,
@@ -130,6 +151,19 @@ private fun NodeMarkerLayers(
opacity = const(CLUSTER_OPACITY),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
onClick = { features ->
val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass
val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass
coroutineScope.launch {
cameraState.animateTo(
cameraState.position.copy(
target = target,
zoom = cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT,
),
)
}
ClickResult.Consume
},
)
// Cluster count labels
@@ -142,13 +176,13 @@ private fun NodeMarkerLayers(
textSize = const(1.2f.em),
)
// Individual node markers
// Individual node markers with per-node background color
CircleLayer(
id = "node-markers",
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(8.dp),
color = const(NodeMarkerColor),
color = feature["background_color"].convertToColor(const(NodeMarkerColor)),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
onClick = { features ->
@@ -162,23 +196,44 @@ private fun NodeMarkerLayers(
},
)
// Precision circles
// Short name labels below node markers
SymbolLayer(
id = "node-labels",
source = nodesSource,
filter = !feature.has("cluster"),
textField = feature["short_name"].asString(),
textSize = const(0.9f.em),
textOffset = offset(0f.em, LABEL_OFFSET_EM.em),
textColor = const(Color.DarkGray),
textAllowOverlap = const(true),
iconAllowOverlap = const(true),
)
// Precision circles — sized by precision_meters property
if (showPrecisionCircle) {
CircleLayer(
id = "node-precision",
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(40.dp), // TODO: scale by precision_meters and zoom
color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
radius = feature["precision_meters"].convertToNumber(const(0f)).dp,
color =
feature["background_color"].convertToColor(
const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
),
opacity = const(PRECISION_CIRCLE_FILL_ALPHA),
strokeWidth = const(1.dp),
strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
strokeColor =
feature["background_color"].convertToColor(
const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
),
strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA),
)
}
}
/** Waypoint markers rendered as symbol layer with emoji icons. */
/** Waypoint markers rendered as symbol layer with emoji icons and click handling. */
@Composable
private fun WaypointMarkerLayers(waypoints: Map<Int, DataPacket>) {
private fun WaypointMarkerLayers(waypoints: Map<Int, DataPacket>, onWaypointClick: (Int) -> Unit) {
val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) }
val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection))
@@ -191,6 +246,15 @@ private fun WaypointMarkerLayers(waypoints: Map<Int, DataPacket>) {
textSize = const(2f.em),
textAllowOverlap = const(true),
iconAllowOverlap = const(true),
onClick = { features ->
val waypointId = features.firstOrNull()?.properties?.get("waypoint_id")?.toString()?.toIntOrNull()
if (waypointId != null) {
onWaypointClick(waypointId)
ClickResult.Consume
} else {
ClickResult.Pass
}
},
)
// Waypoint name labels below emoji

View File

@@ -16,18 +16,23 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACK_ZOOM = 13.0
private const val COORDINATE_SCALE = 1e-7
private const val BOUNDS_PADDING_DP = 48
/**
* Embeddable position-track map showing a polyline with markers for the given positions.
@@ -44,15 +49,28 @@ fun NodeTrackMap(
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val center =
val geoPositions =
remember(positions) {
positions.firstOrNull()?.let { pos ->
positions.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
}
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 = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
)
}
val cameraState =
rememberCameraState(
firstPosition =
@@ -62,6 +80,11 @@ fun NodeTrackMap(
),
)
// Fit camera to bounds when the track has multiple positions.
LaunchedEffect(boundingBox) {
boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
}
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
NodeTrackLayers(
positions = positions,

View File

@@ -16,12 +16,16 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
@@ -29,6 +33,7 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
private const val COORDINATE_SCALE = 1e-7
private const val BOUNDS_PADDING_DP = 64
/**
* Embeddable traceroute map showing forward/return route polylines with hop markers.
@@ -45,16 +50,28 @@ fun TracerouteMap(
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
// Center the camera on the first node with a known position.
val center =
val geoPositions =
remember(tracerouteNodePositions) {
tracerouteNodePositions.values.firstOrNull()?.let { pos ->
tracerouteNodePositions.values.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
}
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 = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
)
}
val cameraState =
rememberCameraState(
firstPosition =
@@ -64,6 +81,11 @@ fun TracerouteMap(
),
)
// Fit camera to bounds when the traceroute has multiple node positions.
LaunchedEffect(boundingBox) {
boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
}
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
TracerouteLayers(
overlay = tracerouteOverlay,

View File

@@ -54,8 +54,8 @@ fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): Feature
put("via_mqtt", node.viaMqtt)
put("snr", node.snr.toDouble())
put("rssi", node.rssi)
put("foreground_color", colors.first)
put("background_color", colors.second)
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))
}
@@ -159,6 +159,14 @@ private const val HALF_SHIFT = 10
private const val HIGH_SURROGATE_BASE = 0xD800
private const val LOW_SURROGATE_BASE = 0xDC00
private const val SURROGATE_MASK = 0x3FF
private const val HEX_COLOR_MASK = 0xFFFFFF
/** Convert an ARGB color integer to a hex color string (e.g. "#FF6750A4") for MapLibre expressions. */
@Suppress("MagicNumber")
internal fun intToHexColor(argb: Int): String {
val rgb = argb and HEX_COLOR_MASK
return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
}
/** Convert a Unicode code point integer to its emoji string representation. */
internal fun convertIntToEmoji(codePoint: Int): String = try {

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import androidx.compose.runtime.Composable
import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.rememberDefaultLocationProvider
@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import androidx.compose.runtime.Composable
import org.maplibre.compose.location.LocationProvider
/** Desktop has no location provider — return null so the UI disables location tracking. */
@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = null