diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index b9a8d3e5a..58df5e50d 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -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
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 9b97e57fb..11db13fbc 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -28,6 +28,7 @@
online
role %1$s
signal %1$s
+ Hop %1$d: %2$d nodes
Accept
Acknowledgements
@@ -737,6 +738,8 @@
Hide password
History return max
History return window
+ No nodes heard in this window
+ Nodes per Hop
Number of Hops
Hops Away
Host
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt
new file mode 100644
index 000000000..b7003fea0
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt
@@ -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 .
+ */
+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, cutoffSecs: Int?): List {
+ 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, 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, 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),
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt
new file mode 100644
index 000000000..a928fff7d
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt
@@ -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 .
+ */
+@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 = 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 = {}) } }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 0e203cfc9..8397df596 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -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,
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt
new file mode 100644
index 000000000..61398eb26
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt
@@ -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 .
+ */
+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))
+ }
+}
diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
index 99b03c6e8..ce5977a87 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
@@ -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()
+}
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png
new file mode 100644
index 000000000..98dcc445a
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png
new file mode 100644
index 000000000..9f2d855e9
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png
new file mode 100644
index 000000000..91a7c876d
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png
new file mode 100644
index 000000000..795a9ca24
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png differ