From 70a08c9d31cfdfd1499d795ffaa388c7eca3dc2a Mon Sep 17 00:00:00 2001 From: Robert-0410 <62630290+Robert-0410@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:02:32 -0800 Subject: [PATCH] feat: Scrollable Signal Chart (#1505) * Removed repeated calculation. * Centralized the radius used to plot points and draw lines within GraphUtil.kt. * Updated the signal metrics chart to use the scroll features. * SignalMetricsChart long method warning suppression. --- .../mesh/ui/components/DeviceMetrics.kt | 10 +- .../mesh/ui/components/SignalMetrics.kt | 175 ++++++++---------- .../com/geeksville/mesh/util/GraphUtil.kt | 6 +- 3 files changed, 90 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt index 6f97d346e..77c15bd8c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -65,6 +65,7 @@ import com.geeksville.mesh.ui.BatteryInfo import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.ui.theme.Orange +import com.geeksville.mesh.util.GraphUtil import com.geeksville.mesh.util.GraphUtil.plotPoint import com.geeksville.mesh.util.GraphUtil.createPath @@ -155,12 +156,12 @@ private fun DeviceMetricsChart( Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - val scrollState = rememberScrollState() + val scrollState = rememberScrollState() val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp val dp by remember(key1 = selectedTime) { - mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong())) + mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) } Row { @@ -195,7 +196,6 @@ private fun DeviceMetricsChart( val height = size.height val width = size.width - val dataPointRadius = 2.dp.toPx() for (i in telemetries.indices) { val telemetry = telemetries[i] @@ -207,7 +207,6 @@ private fun DeviceMetricsChart( plotPoint( drawContext = drawContext, color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], - radius = dataPointRadius, x = x, value = telemetry.deviceMetrics.channelUtilization, divisor = MAX_PERCENT_VALUE @@ -217,7 +216,6 @@ private fun DeviceMetricsChart( plotPoint( drawContext = drawContext, color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], - radius = dataPointRadius, x = x, value = telemetry.deviceMetrics.airUtilTx, divisor = MAX_PERCENT_VALUE @@ -246,7 +244,7 @@ private fun DeviceMetricsChart( path = path, color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], style = Stroke( - width = dataPointRadius, + width = GraphUtil.RADIUS, cap = StrokeCap.Round ) ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt index 29b30d19c..6a62ca1ec 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -17,9 +17,8 @@ package com.geeksville.mesh.ui.components -import android.graphics.Paint -import android.graphics.Typeface import androidx.compose.foundation.Canvas +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,8 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.Card import androidx.compose.material.Surface @@ -46,11 +47,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -60,11 +58,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.R import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC -import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT -import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA -import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT +import com.geeksville.mesh.util.GraphUtil.plotPoint private val METRICS_COLORS = listOf(Color.Green, Color.Blue) @@ -108,6 +105,7 @@ fun SignalMetricsScreen( .fillMaxWidth() .fillMaxHeight(fraction = 0.33f), meshPackets = data.reversed(), + selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true } ) @@ -126,21 +124,30 @@ fun SignalMetricsScreen( } } +@Suppress("LongMethod") @Composable private fun SignalMetricsChart( modifier: Modifier = Modifier, meshPackets: List, + selectedTime: TimeFrame, promptInfoDialog: () -> Unit ) { - ChartHeader(amount = meshPackets.size) if (meshPackets.isEmpty()) { return } + val (oldest, newest) = remember(key1 = meshPackets) { + Pair( + meshPackets.minBy { it.rxTime }, + meshPackets.maxBy { it.rxTime } + ) + } + val timeDiff = newest.rxTime - oldest.rxTime + TimeLabels( - oldest = meshPackets.first().rxTime, - newest = meshPackets.last().rxTime + oldest = oldest.rxTime, + newest = newest.rxTime ) Spacer(modifier = Modifier.height(16.dp)) @@ -149,51 +156,75 @@ private fun SignalMetricsChart( val snrDiff = Metric.SNR.difference() val rssiDiff = Metric.RSSI.difference() - Box(contentAlignment = Alignment.TopStart) { + val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp + val dp by remember(key1 = selectedTime) { + mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong())) + } - ChartOverlay( - modifier = modifier, - lineColors = List(size = 5) { graphColor }, - labelColor = METRICS_COLORS[Metric.SNR.ordinal], - minValue = Metric.SNR.min, - maxValue = Metric.SNR.max, - leaveSpace = true + Row { + YAxisLabels( + modifier = modifier.weight(weight = .1f), + METRICS_COLORS[Metric.RSSI.ordinal], + minValue = Metric.RSSI.min, + maxValue = Metric.RSSI.max, ) - LeftYLabels(modifier = modifier, labelColor = METRICS_COLORS[Metric.RSSI.ordinal]) - - /* Plot SNR and RSSI */ - Canvas(modifier = modifier) { - - val height = size.height - val width = size.width - 28.dp.toPx() - val spacing = LEFT_LABEL_SPACING.dp.toPx() - val spacePerEntry = (width - spacing) / meshPackets.size - - /* Plot */ - val dataPointRadius = 2.dp.toPx() - for ((i, packet) in meshPackets.withIndex()) { - - val x = spacing + i * spacePerEntry + Box( + contentAlignment = Alignment.TopStart, + modifier = Modifier + .horizontalScroll(state = scrollState, reverseScrolling = true) + .weight(1f) + ) { + HorizontalLinesOverlay( + modifier.width(dp), + lineColors = List(size = 5) { graphColor }, + minValue = Metric.SNR.min, + maxValue = Metric.SNR.max + ) - /* SNR */ - val snrRatio = (packet.rxSnr - Metric.SNR.min) / snrDiff - val ySNR = height - (snrRatio * height) - drawCircle( - color = METRICS_COLORS[Metric.SNR.ordinal], - radius = dataPointRadius, - center = Offset(x, ySNR) - ) + TimeAxisOverlay( + modifier.width(dp), + oldest = oldest.rxTime, + newest = newest.rxTime, + selectedTime.lineInterval() + ) - /* RSSI */ - val rssiRatio = (packet.rxRssi - Metric.RSSI.min) / rssiDiff - val yRssi = height - (rssiRatio * height) - drawCircle( - color = METRICS_COLORS[Metric.RSSI.ordinal], - radius = dataPointRadius, - center = Offset(x, yRssi) - ) + /* Plot SNR and RSSI */ + Canvas(modifier = modifier.width(dp)) { + val width = size.width + /* Plot */ + for (packet in meshPackets) { + + val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff + val x = xRatio * width + + /* SNR */ + plotPoint( + drawContext = drawContext, + color = METRICS_COLORS[Metric.SNR.ordinal], + x = x, + value = packet.rxSnr - Metric.SNR.min, + divisor = snrDiff + ) + + /* RSSI */ + plotPoint( + drawContext = drawContext, + color = METRICS_COLORS[Metric.RSSI.ordinal], + x = x, + value = packet.rxRssi - Metric.RSSI.min, + divisor = rssiDiff + ) + } } } + YAxisLabels( + modifier = modifier.weight(weight = .1f), + METRICS_COLORS[Metric.SNR.ordinal], + minValue = Metric.SNR.min, + maxValue = Metric.SNR.max, + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -203,48 +234,6 @@ private fun SignalMetricsChart( Spacer(modifier = Modifier.height(16.dp)) } -/** - * Draws a set of Y labels on the left side of the graph. - * Currently only used for the RSSI labels. - */ -@Composable -private fun LeftYLabels( - modifier: Modifier, - labelColor: Color, -) { - val range = Metric.RSSI.difference() - val verticalSpacing = range / LINE_LIMIT - val density = LocalDensity.current - Canvas(modifier = modifier) { - - val height = size.height - - /* Y Labels */ - - val textPaint = Paint().apply { - color = labelColor.toArgb() - textAlign = Paint.Align.LEFT - textSize = density.run { 12.dp.toPx() } - typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) - alpha = TEXT_PAINT_ALPHA - } - drawContext.canvas.nativeCanvas.apply { - var label = Metric.RSSI.min - for (i in 0..LINE_LIMIT) { - val ratio = (label - Metric.RSSI.min) / range - val y = height - (ratio * height) - drawText( - "${label.toInt()}", - 4.dp.toPx(), - y + 4.dp.toPx(), - textPaint - ) - label += verticalSpacing - } - } - } -} - @Composable private fun SignalMetricsCard(meshPacket: MeshPacket) { val time = meshPacket.rxTime * MS_PER_SEC diff --git a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt index 3e4eb6500..95b737b97 100644 --- a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt +++ b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.util +import android.content.res.Resources import androidx.compose.ui.graphics.Path import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -25,6 +26,8 @@ import com.geeksville.mesh.TelemetryProtos.Telemetry object GraphUtil { + val RADIUS = Resources.getSystem().displayMetrics.density * 2 + /** * @param value Must be zero-scaled before passing. * @param divisor The range for the data set. @@ -32,7 +35,6 @@ object GraphUtil { fun plotPoint( drawContext: DrawContext, color: Color, - radius: Float, x: Float, value: Float, divisor: Float, @@ -42,7 +44,7 @@ object GraphUtil { val y = height - (ratio * height) drawContext.canvas.drawCircle( center = Offset(x, y), - radius = radius, + radius = RADIUS, paint = androidx.compose.ui.graphics.Paint().apply { this.color = color } ) }