feat: Scrollable x axis (#1445)

* Started horizontal scrolling for DeviceMetrics. Drawing lines based on the TimeFrame and setting the dp.

* Wrote YAxisLabels(), it will replace the Y labels portion of the ChartOverlay(). The composable works for either side of the graph.

* Wrote HorizontalLinesOverlay(), it will replace the horizontal lines portion of the ChartOverlay().

* Updated the data points to use their actual x values.

* Based the width of the scrollable graph on time.

* Added a date label to the TimeAxisOverlay.
pull/1321/head
Robert-0410 2024-12-01 02:22:54 -08:00 zatwierdzone przez GitHub
rodzic 3c581f81a8
commit b3f4929cf4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
5 zmienionych plików z 330 dodań i 90 usunięć

Wyświetl plik

@ -21,6 +21,8 @@ import android.app.Application
import android.content.SharedPreferences
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -120,6 +122,48 @@ enum class TimeFrame(
} else {
System.currentTimeMillis() / 1000 - this.seconds
}
/**
* The time interval to draw the vertical lines representing
* time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long {
return when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
}
/**
* Calculates the needed [Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {
val timePerScreen = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}
private fun MeshPacket.hasValidSignal(): Boolean =

Wyświetl plik

@ -59,21 +59,25 @@ import androidx.compose.ui.unit.sp
import com.geeksville.mesh.R
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.TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING
import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC
import java.text.DateFormat
object CommonCharts {
val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
const val X_AXIS_SPACING = 8f
const val LEFT_LABEL_SPACING = 36
const val MS_PER_SEC = 1000.0f
const val MS_PER_SEC = 1000L
const val LINE_LIMIT = 4
const val TEXT_PAINT_ALPHA = 192
}
private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
private const val LINE_ON = 10f
private const val LINE_OFF = 20f
private const val DATE_Y = 32f
data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false)
@ -100,6 +104,7 @@ fun ChartHeader(amount: Int) {
* @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart.
* @param leaveSpace When true the lines will leave space for Y labels on the left side of the graph.
*/
@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLines()", level = DeprecationLevel.WARNING)
@Composable
fun ChartOverlay(
modifier: Modifier,
@ -160,14 +165,162 @@ fun ChartOverlay(
}
}
/**
* Draws chart lines with respect to the Y-axis range; defined by (`maxValue` - `minValue`).
*
* @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart.
*/
@Composable
fun HorizontalLinesOverlay(
modifier: Modifier,
lineColors: List<Color>,
minValue: Float,
maxValue: Float,
) {
val range = maxValue - minValue
val verticalSpacing = range / LINE_LIMIT
Canvas(modifier = modifier) {
val lineStart = 0f
val height = size.height
val width = size.width
/* Horizontal Lines */
var lineY = minValue
for (i in 0..LINE_LIMIT) {
val ratio = (lineY - minValue) / range
val y = height - (ratio * height)
drawLine(
start = Offset(lineStart, y),
end = Offset(width, y),
color = lineColors[i],
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
)
lineY += verticalSpacing
}
}
}
/**
* Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`).
*/
@Composable
fun YAxisLabels(
modifier: Modifier,
labelColor: Color,
minValue: Float,
maxValue: Float,
) {
val range = maxValue - minValue
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 = minValue
for (i in 0..LINE_LIMIT) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText(
"${label.toInt()}",
0f,
y + 4.dp.toPx(),
textPaint
)
label += verticalSpacing
}
}
}
}
/**
* Draws the vertical lines to help the user relate the plotted data within a time frame.
*/
@Composable
fun TimeAxisOverlay(
modifier: Modifier,
oldest: Int,
newest: Int,
timeInterval: Long
) {
val range = newest - oldest
val density = LocalDensity.current
val lineColor = MaterialTheme.colors.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width - 28.dp.toPx()
/* Cut out the time remaining in order to place the lines on the dot. */
val timeRemaining = oldest % timeInterval
var current = oldest.toLong()
current -= timeRemaining
current += timeInterval
val textPaint = Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
/* Vertical Lines with labels */
drawContext.canvas.nativeCanvas.apply {
while (current <= newest) {
val ratio = (current - oldest).toFloat() / range
val x = (ratio * width)
drawLine(
start = Offset(x, 0f),
end = Offset(x, height),
color = lineColor,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
)
/* Time */
drawText(
TIME_FORMAT.format(current * MS_PER_SEC),
x,
0f,
textPaint
)
/* Date */
drawText(
DATE_FORMAT.format(current * MS_PER_SEC),
x,
DATE_Y,
textPaint
)
current += timeInterval
}
}
}
}
/**
* Draws the `oldest` and `newest` times for the respective telemetry data.
* Expects time in milliseconds
* Expects time in seconds.
*/
@Composable
fun TimeLabels(
oldest: Float,
newest: Float
oldest: Int,
newest: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -175,14 +328,14 @@ fun TimeLabels(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = TIME_FORMAT.format(oldest),
text = DATE_TIME_FORMAT.format(oldest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = TIME_FORMAT.format(newest),
text = DATE_TIME_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp

Wyświetl plik

@ -18,6 +18,7 @@
package com.geeksville.mesh.ui.components
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
@ -28,8 +29,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.MaterialTheme
@ -48,6 +51,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
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
@ -57,10 +61,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.BatteryInfo
import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING
import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.theme.Orange
private val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan)
@ -102,6 +106,7 @@ fun DeviceMetricsScreen(
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f),
data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true }
)
@ -126,95 +131,133 @@ fun DeviceMetricsScreen(
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) return
val (oldest, newest) = remember(key1 = telemetries) {
Pair(
telemetries.minBy { it.time },
telemetries.maxBy { it.time }
)
}
val timeDiff = newest.time - oldest.time
TimeLabels(
oldest = telemetries.first().time * MS_PER_SEC,
newest = telemetries.last().time * MS_PER_SEC
oldest = oldest.time,
newest = newest.time
)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colors.onSurface
val spacing = X_AXIS_SPACING
val scrollState = rememberScrollState()
Box(contentAlignment = Alignment.TopStart) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
}
/*
* The order of the colors are with respect to the ChUtil.
* 25 - 49 Orange
* 50 - 100 Red
*/
ChartOverlay(
modifier,
Row {
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier
.horizontalScroll(scrollState)
.weight(1f)
) {
/*
* The order of the colors are with respect to the ChUtil.
* 25 - 49 Orange
* 50 - 100 Red
*/
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor),
minValue = 0f,
maxValue = 100f
)
TimeAxisOverlay(
modifier.width(dp),
oldest = oldest.time,
newest = newest.time,
selectedTime.lineInterval()
)
/* Plot Battery Line, ChUtil, and AirUtilTx */
Canvas(modifier = modifier.width(dp)) {
val height = size.height
val width = size.width
val dataPointRadius = 2.dp.toPx()
val strokePath = Path().apply {
for (i in telemetries.indices) {
val telemetry = telemetries[i]
/* x-value for all three */
val x1Ratio = (telemetry.time - oldest.time).toFloat() / timeDiff
val x1 = x1Ratio * width
/* Channel Utilization */
val chUtilRatio =
telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE
val yChUtil = height - (chUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal],
radius = dataPointRadius,
center = Offset(x1, yChUtil)
)
/* Air Utilization Transmit */
val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE
val yAirUtil = height - (airUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal],
radius = dataPointRadius,
center = Offset(x1, yAirUtil)
)
/* Battery line */
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
val y1Ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val y1 = height - (y1Ratio * height)
val x2Ratio = (nextTelemetry.time - oldest.time).toFloat() / timeDiff
val x2 = x2Ratio * width
val y2Ratio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val y2 = height - (y2Ratio * height)
if (i == 0) {
moveTo(x1, y1)
}
quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f)
}
}
/* Battery Line */
drawPath(
path = strokePath,
color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal],
style = Stroke(
width = dataPointRadius,
cap = StrokeCap.Round
)
)
}
}
YAxisLabels(
modifier = modifier.weight(weight = .1f),
graphColor,
lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor),
minValue = 0f,
maxValue = 100f
)
/* Plot Battery Line, ChUtil, and AirUtilTx */
Canvas(modifier = modifier) {
val height = size.height
val width = size.width - 28.dp.toPx()
val spacePerEntry = (width - spacing) / telemetries.size
val dataPointRadius = 2.dp.toPx()
var lastX: Float
val strokePath = Path().apply {
for (i in telemetries.indices) {
val telemetry = telemetries[i]
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
val leftRatio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val rightRatio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val x1 = spacing + i * spacePerEntry
val y1 = height - (leftRatio * height)
/* Channel Utilization */
val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE
val yChUtil = height - (chUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal],
radius = dataPointRadius,
center = Offset(x1, yChUtil)
)
/* Air Utilization Transmit */
val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE
val yAirUtil = height - (airUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal],
radius = dataPointRadius,
center = Offset(x1, yAirUtil)
)
val x2 = spacing + (i + 1) * spacePerEntry
val y2 = height - (rightRatio * height)
if (i == 0) {
moveTo(x1, y1)
}
lastX = (x1 + x2) / 2f
quadraticTo(x1, y1, lastX, (y1 + y2) / 2f)
}
}
/* Battery Line */
drawPath(
path = strokePath,
color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal],
style = Stroke(
width = dataPointRadius,
cap = StrokeCap.Round
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -246,7 +289,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = TIME_FORMAT.format(time),
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)

Wyświetl plik

@ -63,7 +63,7 @@ import com.geeksville.mesh.copy
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING
import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
private val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue, Color.Green)
private enum class Environment {
@ -170,8 +170,8 @@ private fun EnvironmentMetricsChart(
return
}
TimeLabels(
oldest = telemetries.first().time * MS_PER_SEC,
newest = telemetries.last().time * MS_PER_SEC
oldest = telemetries.first().time,
newest = telemetries.last().time
)
Spacer(modifier = Modifier.height(16.dp))
@ -428,7 +428,7 @@ private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahre
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = TIME_FORMAT.format(time),
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)

Wyświetl plik

@ -64,7 +64,7 @@ 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.TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
private val METRICS_COLORS = listOf(Color.Green, Color.Blue)
@ -139,8 +139,8 @@ private fun SignalMetricsChart(
}
TimeLabels(
oldest = meshPackets.first().rxTime * MS_PER_SEC,
newest = meshPackets.last().rxTime * MS_PER_SEC
oldest = meshPackets.first().rxTime,
newest = meshPackets.last().rxTime
)
Spacer(modifier = Modifier.height(16.dp))
@ -274,7 +274,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = TIME_FORMAT.format(time),
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)