feat(node): histogram of nodes per hop distance (#5745) (#6146)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-07 21:13:38 -05:00
committed by GitHub
parent 337f0a05fb
commit ae0bdd9e97
11 changed files with 325 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ a11y_node_offline
a11y_node_online
a11y_node_role
a11y_node_signal
a11y_nodes_at_hop
accept
acknowledgements
### ACTION ###
@@ -713,6 +714,8 @@ hide_layer
hide_password
history_return_max
history_return_window
hop_histogram_empty
hop_histogram_title
hop_limit
hops_away
host

View File

@@ -28,6 +28,7 @@
<string name="a11y_node_online">online</string>
<string name="a11y_node_role">role %1$s</string>
<string name="a11y_node_signal">signal %1$s</string>
<string name="a11y_nodes_at_hop">Hop %1$d: %2$d nodes</string>
<string name="accept">Accept</string>
<string name="acknowledgements">Acknowledgements</string>
<!-- ACTION -->
@@ -737,6 +738,8 @@
<string name="hide_password">Hide password</string>
<string name="history_return_max">History return max</string>
<string name="history_return_window">History return window</string>
<string name="hop_histogram_empty">No nodes heard in this window</string>
<string name="hop_histogram_title">Nodes per Hop</string>
<string name="hop_limit">Number of Hops</string>
<string name="hops_away">Hops Away</string>
<string name="host">Host</string>

View File

@@ -0,0 +1,174 @@
/*
* 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.node.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_nodes_at_hop
import org.meshtastic.core.resources.all_time
import org.meshtastic.core.resources.eight_hours
import org.meshtastic.core.resources.hop_histogram_empty
import org.meshtastic.core.resources.hop_histogram_title
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.twenty_four_hours
import kotlin.time.Duration.Companion.hours
/**
* Counts nodes at each hop distance, as a list indexed by hop (element 0 = direct nodes). The list is contiguous from
* hop 0 up to the furthest hop observed, so zero-count gaps in the middle are preserved. Nodes with an unknown hop
* (`hopsAway < 0`) are excluded. When [cutoffSecs] is non-null, only nodes with `lastHeard >= cutoffSecs` are counted.
* Returns an empty list when nothing qualifies.
*/
fun hopHistogram(nodes: List<Node>, cutoffSecs: Int?): List<Int> {
val counts =
nodes
.filter { it.hopsAway >= 0 && (cutoffSecs == null || it.lastHeard >= cutoffSecs) }
.groupingBy { it.hopsAway }
.eachCount()
val maxHop = counts.keys.maxOrNull() ?: return emptyList()
return (0..maxHop).map { counts[it] ?: 0 }
}
/**
* Bottom sheet showing how many mesh nodes sit at each hop distance, filtered to a "last heard" [HopWindow]. Gives a
* quick sense of how busy and dispersed the local mesh is (see issue #5745).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeHopHistogramSheet(nodes: List<Node>, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var window by remember { mutableStateOf(HopWindow.EIGHT_HOURS) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
HopHistogramContent(nodes = nodes, window = window, onSelectWindow = { window = it })
}
}
/** Stateless sheet content — split out from [NodeHopHistogramSheet] so it can be previewed without a sheet host. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun HopHistogramContent(nodes: List<Node>, window: HopWindow, onSelectWindow: (HopWindow) -> Unit) {
val cutoffSecs =
remember(window) { window.hours?.let { (nowInstant.epochSeconds - it.hours.inWholeSeconds).toInt() } }
val buckets = remember(nodes, cutoffSecs) { hopHistogram(nodes, cutoffSecs) }
val maxCount = buckets.maxOrNull() ?: 0
Column(
modifier =
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.hop_histogram_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() },
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HopWindow.entries.forEach { option ->
FilterChip(
selected = option == window,
onClick = { onSelectWindow(option) },
label = { Text(stringResource(option.label)) },
)
}
}
if (buckets.isEmpty()) {
Text(
text = stringResource(Res.string.hop_histogram_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
buckets.forEachIndexed { hop, count -> HopBar(hop = hop, count = count, maxCount = maxCount) }
}
}
}
@Composable
private fun HopBar(hop: Int, count: Int, maxCount: Int) {
// Merge the row into one spoken node so a screen reader announces "Hop 3: 2 nodes" rather than
// reading the hop number, progress-bar percentage, and count as three disjoint elements.
val description = stringResource(Res.string.a11y_nodes_at_hop, hop, count)
Row(
modifier = Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = hop.toString(),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 20.dp),
)
LinearProgressIndicator(
progress = { if (maxCount > 0) count.toFloat() / maxCount else 0f },
modifier = Modifier.weight(1f),
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
Text(
text = count.toString(),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.widthIn(min = 28.dp),
)
}
}
/** "Last heard" windows offered above the histogram. A `null` [hours] means no time filter (all known nodes). */
@Suppress("MagicNumber")
enum class HopWindow(val hours: Long?, val label: StringResource) {
ALL(null, Res.string.all_time),
ONE_HOUR(1, Res.string.one_hour),
EIGHT_HOURS(8, Res.string.eight_hours),
ONE_DAY(24, Res.string.twenty_four_hours),
}

View File

@@ -0,0 +1,47 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber", "PreviewPublic")
package org.meshtastic.feature.node.component
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.theme.AppTheme
// A spread of hop distances with hop 4 intentionally missing, to show the preserved zero-count gap.
// Built inside a function (not an eager top-level val) so it can't poison this file's class initializer.
private fun sampleNodes(): List<Node> = buildList {
listOf(0 to 8, 1 to 12, 2 to 5, 3 to 2, 5 to 3).forEach { (hop, count) ->
repeat(count) { i -> add(Node(num = hop * 1000 + i, hopsAway = hop)) }
}
}
@PreviewLightDark
@Composable
fun HopHistogramContentPreview() {
val nodes = remember { sampleNodes() }
AppTheme { Surface { HopHistogramContent(nodes = nodes, window = HopWindow.ALL, onSelectWindow = {}) } }
}
@PreviewLightDark
@Composable
fun HopHistogramEmptyPreview() {
AppTheme { Surface { HopHistogramContent(nodes = emptyList(), window = HopWindow.ALL, onSelectWindow = {}) } }
}

View File

@@ -62,6 +62,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.hop_histogram_title
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.node_list_help_title
import org.meshtastic.core.resources.nodes
@@ -77,6 +78,7 @@ import org.meshtastic.core.ui.component.NodeItemCompact
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
@@ -84,6 +86,7 @@ import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeHopHistogramSheet
import org.meshtastic.feature.node.component.NodeListHelp
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -145,6 +148,11 @@ fun NodeListScreen(
NodeListHelp(onDismiss = { showHelpSheet = false })
}
var showHopHistogram by remember { mutableStateOf(false) }
if (showHopHistogram) {
NodeHopHistogramSheet(nodes = unfilteredNodes, onDismiss = { showHopHistogram = false })
}
var showShareContact by remember { mutableStateOf(false) }
if (showShareContact) {
SharedContactDialog(contact = ourNode, onDismiss = { showShareContact = false })
@@ -161,6 +169,12 @@ fun NodeListScreen(
canNavigateUp = false,
onNavigateUp = {},
actions = {
IconButton(onClick = { showHopHistogram = true }) {
Icon(
imageVector = MeshtasticIcons.BarChart,
contentDescription = stringResource(Res.string.hop_histogram_title),
)
}
IconButton(onClick = { showHelpSheet = true }) {
Icon(
imageVector = MeshtasticIcons.Info,

View File

@@ -0,0 +1,64 @@
/*
* 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.node.component
import org.meshtastic.core.model.Node
import kotlin.test.Test
import kotlin.test.assertEquals
/** Tests for [hopHistogram] — the pure bucketing behind the nodes-per-hop view. */
class NodeHopHistogramTest {
private fun node(hops: Int, lastHeard: Int = 1_000) =
Node(num = hops * 100 + lastHeard, hopsAway = hops, lastHeard = lastHeard)
@Test
fun empty_input_gives_empty_list() {
assertEquals(emptyList(), hopHistogram(emptyList(), cutoffSecs = null))
}
@Test
fun buckets_are_contiguous_and_preserve_middle_gaps() {
val nodes = listOf(node(0), node(0), node(1), node(3))
// hop 2 has zero nodes but must still appear between 1 and 3
assertEquals(listOf(2, 1, 0, 1), hopHistogram(nodes, cutoffSecs = null))
}
@Test
fun unknown_hops_are_excluded() {
val nodes = listOf(node(-1), node(0), node(-1))
assertEquals(listOf(1), hopHistogram(nodes, cutoffSecs = null))
}
@Test
fun all_unknown_gives_empty_list() {
assertEquals(emptyList(), hopHistogram(listOf(node(-1), node(-1)), cutoffSecs = null))
}
@Test
fun cutoff_drops_nodes_heard_before_it() {
val nodes = listOf(node(0, lastHeard = 100), node(0, lastHeard = 500), node(1, lastHeard = 500))
// cutoff=300 keeps only lastHeard >= 300
assertEquals(listOf(1, 1), hopHistogram(nodes, cutoffSecs = 300))
}
@Test
fun null_cutoff_keeps_everything_regardless_of_last_heard() {
val nodes = listOf(node(0, lastHeard = 0), node(0, lastHeard = 999))
assertEquals(listOf(2), hopHistogram(nodes, cutoffSecs = null))
}
}

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.android.tools.screenshot.PreviewTest
import org.meshtastic.feature.node.component.DeviceActionsLocalPreview
import org.meshtastic.feature.node.component.DeviceActionsRemotePreview
import org.meshtastic.feature.node.component.HopHistogramContentPreview
import org.meshtastic.feature.node.component.HopHistogramEmptyPreview
import org.meshtastic.feature.node.component.NodeDetailsSectionPreview
import org.meshtastic.feature.node.component.NodeDetailsSectionSignedPreview
import org.meshtastic.feature.node.component.NodeItemCompactActivePreview
@@ -215,3 +217,21 @@ fun ScreenshotNodeItemCompleteOnlineRemote() {
fun ScreenshotNodeDetailContentMinimal() {
NodeDetailContentMinimalPreview()
}
// ---------------------------------------------------------------------------
// Nodes-per-hop histogram (issue #5745)
// ---------------------------------------------------------------------------
@PreviewTest
@PreviewLightDark
@Composable
fun ScreenshotHopHistogramContent() {
HopHistogramContentPreview()
}
@PreviewTest
@PreviewLightDark
@Composable
fun ScreenshotHopHistogramEmpty() {
HopHistogramEmptyPreview()
}