From 2aab65bd1badd5830dc696e223a90d0a9dee2638 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 19:25:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(map):=20add=20feature=20parity=20=E2=80=94?= =?UTF-8?q?=20filters,=20style=20selector,=20waypoint=20dialog,=20cluster?= =?UTF-8?q?=20zoom,=20bounds=20fitting,=20location=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../feature/map/LocationProviderFactory.kt | 23 +++ .../feature/map/LocationProviderFactory.kt | 28 +++ .../org/meshtastic/feature/map/MapScreen.kt | 151 +++++++++++++++- .../map/component/EditWaypointDialog.kt | 168 ++++++++++++++++++ .../map/component/MapFilterDropdown.kt | 129 ++++++++++++++ .../feature/map/component/MapStyleSelector.kt | 76 ++++++++ .../map/component/MaplibreMapContent.kt | 86 +++++++-- .../feature/map/component/NodeTrackMap.kt | 27 ++- .../feature/map/component/TracerouteMap.kt | 28 ++- .../feature/map/util/GeoJsonConverters.kt | 12 +- .../feature/map/LocationProviderFactory.kt | 23 +++ .../feature/map/LocationProviderFactory.kt | 23 +++ 12 files changed, 750 insertions(+), 24 deletions(-) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 000000000..d98dc681a --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -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 . + */ +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() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 000000000..7bda5766d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -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 . + */ +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? diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 92291d3f3..d610935e0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -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(null) } + var editingWaypointId by remember { mutableStateOf(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, + ) + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt new file mode 100644 index 000000000..1392ab1aa --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -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 . + */ +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)) + } + }, + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt new file mode 100644 index 000000000..53f3d5dc2 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt @@ -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 . + */ +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, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt new file mode 100644 index 000000000..a50c9e2a7 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt @@ -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 . + */ +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 + }, + ) + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 19e65518b..f749d3a89 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -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, 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) { +private fun WaypointMarkerLayers(waypoints: Map, 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) { 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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 9c7e1220d..043772898 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -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, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index e198397c4..9cf0fe133 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -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, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index a28c76627..622703f05 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -54,8 +54,8 @@ fun nodesToFeatureCollection(nodes: List, 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 { diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 000000000..d98dc681a --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -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 . + */ +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() diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 000000000..717deb534 --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -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 . + */ +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