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.
pull/1490/head
Robert-0410 2025-01-03 04:02:32 -08:00 zatwierdzone przez GitHub
rodzic d14a8de78e
commit 70a08c9d31
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
3 zmienionych plików z 87 dodań i 98 usunięć

Wyświetl plik

@ -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.MS_PER_SEC
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.theme.Orange 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.plotPoint
import com.geeksville.mesh.util.GraphUtil.createPath import com.geeksville.mesh.util.GraphUtil.createPath
@ -155,12 +156,12 @@ private fun DeviceMetricsChart(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colors.onSurface val graphColor = MaterialTheme.colors.onSurface
val scrollState = rememberScrollState()
val scrollState = rememberScrollState()
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp val screenWidth = configuration.screenWidthDp
val dp by remember(key1 = selectedTime) { val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong())) mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
} }
Row { Row {
@ -195,7 +196,6 @@ private fun DeviceMetricsChart(
val height = size.height val height = size.height
val width = size.width val width = size.width
val dataPointRadius = 2.dp.toPx()
for (i in telemetries.indices) { for (i in telemetries.indices) {
val telemetry = telemetries[i] val telemetry = telemetries[i]
@ -207,7 +207,6 @@ private fun DeviceMetricsChart(
plotPoint( plotPoint(
drawContext = drawContext, drawContext = drawContext,
color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal],
radius = dataPointRadius,
x = x, x = x,
value = telemetry.deviceMetrics.channelUtilization, value = telemetry.deviceMetrics.channelUtilization,
divisor = MAX_PERCENT_VALUE divisor = MAX_PERCENT_VALUE
@ -217,7 +216,6 @@ private fun DeviceMetricsChart(
plotPoint( plotPoint(
drawContext = drawContext, drawContext = drawContext,
color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal],
radius = dataPointRadius,
x = x, x = x,
value = telemetry.deviceMetrics.airUtilTx, value = telemetry.deviceMetrics.airUtilTx,
divisor = MAX_PERCENT_VALUE divisor = MAX_PERCENT_VALUE
@ -246,7 +244,7 @@ private fun DeviceMetricsChart(
path = path, path = path,
color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal],
style = Stroke( style = Stroke(
width = dataPointRadius, width = GraphUtil.RADIUS,
cap = StrokeCap.Round cap = StrokeCap.Round
) )
) )

Wyświetl plik

@ -17,9 +17,8 @@
package com.geeksville.mesh.ui.components package com.geeksville.mesh.ui.components
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.Surface import androidx.compose.material.Surface
@ -46,11 +47,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.MeshProtos.MeshPacket
import com.geeksville.mesh.R import com.geeksville.mesh.R
import com.geeksville.mesh.model.MetricsViewModel 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.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.ui.components.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.util.GraphUtil.plotPoint
private val METRICS_COLORS = listOf(Color.Green, Color.Blue) private val METRICS_COLORS = listOf(Color.Green, Color.Blue)
@ -108,6 +105,7 @@ fun SignalMetricsScreen(
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(fraction = 0.33f), .fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(), meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true } promptInfoDialog = { displayInfoDialog = true }
) )
@ -126,21 +124,30 @@ fun SignalMetricsScreen(
} }
} }
@Suppress("LongMethod")
@Composable @Composable
private fun SignalMetricsChart( private fun SignalMetricsChart(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>, meshPackets: List<MeshPacket>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit promptInfoDialog: () -> Unit
) { ) {
ChartHeader(amount = meshPackets.size) ChartHeader(amount = meshPackets.size)
if (meshPackets.isEmpty()) { if (meshPackets.isEmpty()) {
return return
} }
val (oldest, newest) = remember(key1 = meshPackets) {
Pair(
meshPackets.minBy { it.rxTime },
meshPackets.maxBy { it.rxTime }
)
}
val timeDiff = newest.rxTime - oldest.rxTime
TimeLabels( TimeLabels(
oldest = meshPackets.first().rxTime, oldest = oldest.rxTime,
newest = meshPackets.last().rxTime newest = newest.rxTime
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -149,52 +156,76 @@ private fun SignalMetricsChart(
val snrDiff = Metric.SNR.difference() val snrDiff = Metric.SNR.difference()
val rssiDiff = Metric.RSSI.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( Row {
modifier = modifier, YAxisLabels(
lineColors = List(size = 5) { graphColor }, modifier = modifier.weight(weight = .1f),
labelColor = METRICS_COLORS[Metric.SNR.ordinal], METRICS_COLORS[Metric.RSSI.ordinal],
minValue = Metric.SNR.min, minValue = Metric.RSSI.min,
maxValue = Metric.SNR.max, maxValue = Metric.RSSI.max,
leaveSpace = true )
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
)
TimeAxisOverlay(
modifier.width(dp),
oldest = oldest.rxTime,
newest = newest.rxTime,
selectedTime.lineInterval()
) )
LeftYLabels(modifier = modifier, labelColor = METRICS_COLORS[Metric.RSSI.ordinal])
/* Plot SNR and RSSI */ /* Plot SNR and RSSI */
Canvas(modifier = modifier) { Canvas(modifier = modifier.width(dp)) {
val width = size.width
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 */ /* Plot */
val dataPointRadius = 2.dp.toPx() for (packet in meshPackets) {
for ((i, packet) in meshPackets.withIndex()) {
val x = spacing + i * spacePerEntry val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
val x = xRatio * width
/* SNR */ /* SNR */
val snrRatio = (packet.rxSnr - Metric.SNR.min) / snrDiff plotPoint(
val ySNR = height - (snrRatio * height) drawContext = drawContext,
drawCircle(
color = METRICS_COLORS[Metric.SNR.ordinal], color = METRICS_COLORS[Metric.SNR.ordinal],
radius = dataPointRadius, x = x,
center = Offset(x, ySNR) value = packet.rxSnr - Metric.SNR.min,
divisor = snrDiff
) )
/* RSSI */ /* RSSI */
val rssiRatio = (packet.rxRssi - Metric.RSSI.min) / rssiDiff plotPoint(
val yRssi = height - (rssiRatio * height) drawContext = drawContext,
drawCircle(
color = METRICS_COLORS[Metric.RSSI.ordinal], color = METRICS_COLORS[Metric.RSSI.ordinal],
radius = dataPointRadius, x = x,
center = Offset(x, yRssi) 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)) Spacer(modifier = Modifier.height(16.dp))
@ -203,48 +234,6 @@ private fun SignalMetricsChart(
Spacer(modifier = Modifier.height(16.dp)) 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 @Composable
private fun SignalMetricsCard(meshPacket: MeshPacket) { private fun SignalMetricsCard(meshPacket: MeshPacket) {
val time = meshPacket.rxTime * MS_PER_SEC val time = meshPacket.rxTime * MS_PER_SEC

Wyświetl plik

@ -17,6 +17,7 @@
package com.geeksville.mesh.util package com.geeksville.mesh.util
import android.content.res.Resources
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -25,6 +26,8 @@ import com.geeksville.mesh.TelemetryProtos.Telemetry
object GraphUtil { object GraphUtil {
val RADIUS = Resources.getSystem().displayMetrics.density * 2
/** /**
* @param value Must be zero-scaled before passing. * @param value Must be zero-scaled before passing.
* @param divisor The range for the data set. * @param divisor The range for the data set.
@ -32,7 +35,6 @@ object GraphUtil {
fun plotPoint( fun plotPoint(
drawContext: DrawContext, drawContext: DrawContext,
color: Color, color: Color,
radius: Float,
x: Float, x: Float,
value: Float, value: Float,
divisor: Float, divisor: Float,
@ -42,7 +44,7 @@ object GraphUtil {
val y = height - (ratio * height) val y = height - (ratio * height)
drawContext.canvas.drawCircle( drawContext.canvas.drawCircle(
center = Offset(x, y), center = Offset(x, y),
radius = radius, radius = RADIUS,
paint = androidx.compose.ui.graphics.Paint().apply { this.color = color } paint = androidx.compose.ui.graphics.Paint().apply { this.color = color }
) )
} }