diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index df5b2698..1a4be6f9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -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 = diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt index e67493af..01d64275 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -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, + 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 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 d44176e1..8eba0e79 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 @@ -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, + 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 ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt index 2c3c35df..60a0e026 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -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 ) 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 54d718b9..a9aa7222 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 @@ -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 )