mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
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:
@@ -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()
|
||||
@@ -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?
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user