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 0e89e5383..f6a9f89f5 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 @@ -57,21 +57,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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.DATE_TIME_FORMAT -import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING import com.geeksville.mesh.ui.components.CommonCharts.MAX_PERCENT_VALUE import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import java.text.DateFormat object CommonCharts { 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 = 1000L - const val LINE_LIMIT = 4 - const val TEXT_PAINT_ALPHA = 192 const val MAX_PERCENT_VALUE = 100f val INFANTRY_BLUE = Color(75, 119, 190) } @@ -81,6 +74,8 @@ private const val LINE_OFF = 20f private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) private const val DATE_Y = 32f +private const val LINE_LIMIT = 4 +private const val TEXT_PAINT_ALPHA = 192 data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false) @@ -100,78 +95,10 @@ fun ChartHeader(amount: Int) { } } -/** - * Draws chart lines and labels with respect to the Y-axis range; defined by (`maxValue` - `minValue`). - * - * @param labelColor The color to be used for the Y labels. - * @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 HorizontalLinesOverlay()", level = DeprecationLevel.WARNING) -@Composable -fun ChartOverlay( - modifier: Modifier, - labelColor: Color, - lineColors: List, - minValue: Float, - maxValue: Float, - leaveSpace: Boolean = false -) { - val range = maxValue - minValue - val verticalSpacing = range / LINE_LIMIT - val density = LocalDensity.current - Canvas(modifier = modifier) { - - val lineStart = if (leaveSpace) LEFT_LABEL_SPACING.dp.toPx() else 0f - val height = size.height - val width = size.width - 28.dp.toPx() - - /* 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 - } - - /* 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()}", - width + 4.dp.toPx(), - y + 4.dp.toPx(), - textPaint - ) - label += verticalSpacing - } - } - } -} - /** * Draws chart lines with respect to the Y-axis. * - * @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart. + * @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart. */ @Composable fun HorizontalLinesOverlay( 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 01f4c3805..941720238 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 @@ -158,8 +158,7 @@ private fun DeviceMetricsChart( val graphColor = MaterialTheme.colors.onSurface val scrollState = rememberScrollState() - val configuration = LocalConfiguration.current - val screenWidth = configuration.screenWidthDp + val screenWidth = LocalConfiguration.current.screenWidthDp val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) } @@ -169,7 +168,7 @@ private fun DeviceMetricsChart( contentAlignment = Alignment.TopStart, modifier = Modifier .horizontalScroll(state = scrollState, reverseScrolling = true) - .weight(1f) + .weight(weight = 1f) ) { /* 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 d1dd19da8..c50dd33a8 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 @@ -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 @@ -31,6 +32,7 @@ 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 @@ -44,13 +46,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.asAndroidPath -import androidx.compose.ui.graphics.asComposePath -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 @@ -62,10 +60,11 @@ import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.copy import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.TimeFrame -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.DATE_TIME_FORMAT import com.geeksville.mesh.ui.components.CommonCharts.INFANTRY_BLUE +import com.geeksville.mesh.util.GraphUtil.createPath +import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient private enum class Environment(val color: Color) { TEMPERATURE(Color.Red), @@ -135,6 +134,7 @@ fun EnvironmentMetricsScreen( .fillMaxWidth() .fillMaxHeight(fraction = 0.33f), telemetries = processedTelemetries.reversed(), + selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true } ) @@ -165,32 +165,31 @@ fun EnvironmentMetricsScreen( private fun EnvironmentMetricsChart( 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, - newest = telemetries.last().time + oldest = oldest.time, + newest = newest.time ) Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - val transparentTemperatureColor = remember { - Environment.TEMPERATURE.color.copy(alpha = 0.5f) - } - val transparentHumidityColor = remember { - Environment.HUMIDITY.color.copy(alpha = 0.5f) - } - val transparentIAQColor = remember { - Environment.IAQ.color.copy(alpha = 0.5f) - } - val spacing = X_AXIS_SPACING - /* Since both temperature and humidity are being plotted we need a combined min and max. */ + /* Grab the combined min and max for all data being plotted. */ val (minTemp, maxTemp) = remember(key1 = telemetries) { Pair( telemetries.minBy { it.environmentMetrics.temperature }, @@ -221,182 +220,128 @@ private fun EnvironmentMetricsChart( ) val diff = max - min - Box(contentAlignment = Alignment.TopStart) { - ChartOverlay( - modifier = modifier, - labelColor = graphColor, - lineColors = List(size = 5) { graphColor }, + val scrollState = rememberScrollState() + val screenWidth = LocalConfiguration.current.screenWidthDp + val dp by remember(key1 = selectedTime) { + mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) + } + + Row { + Box( + contentAlignment = Alignment.TopStart, + modifier = Modifier + .horizontalScroll(state = scrollState, reverseScrolling = true) + .weight(weight = 1f) + ) { + + HorizontalLinesOverlay( + modifier.width(dp), + lineColors = List(size = 5) { graphColor } + ) + + TimeAxisOverlay( + modifier = modifier.width(dp), + oldest = oldest.time, + newest = newest.time, + selectedTime.lineInterval() + ) + + Canvas(modifier = modifier.width(dp)) { + val height = size.height + val width = size.width + + /* Temperature */ + var index = 0 + var first: Int + while (index < telemetries.size) { + first = index + val path = Path() + index = createPath( + telemetries = telemetries, + index = index, + path = path, + oldestTime = oldest.time, + timeRange = timeDiff, + width = width, + timeThreshold = selectedTime.timeThreshold() + ) { i -> + val telemetry = telemetries.getOrNull(i) ?: telemetries.last() + val ratio = (telemetry.environmentMetrics.temperature - min) / diff + val y = height - (ratio * height) + return@createPath y + } + drawPathWithGradient( + path = path, + color = Environment.TEMPERATURE.color, + height = height, + x1 = ((telemetries[index - 1].time - oldest.time).toFloat() / timeDiff) * width, + x2 = ((telemetries[first].time - oldest.time).toFloat() / timeDiff) * width + ) + } + + /* Relative Humidity */ + index = 0 + while (index < telemetries.size) { + first = index + val path = Path() + index = createPath( + telemetries = telemetries, + index = index, + path = path, + oldestTime = oldest.time, + timeRange = timeDiff, + width = width, + timeThreshold = selectedTime.timeThreshold() + ) { i -> + val telemetry = telemetries.getOrNull(i) ?: telemetries.last() + val ratio = (telemetry.environmentMetrics.relativeHumidity - min) / diff + val y = height - (ratio * height) + return@createPath y + } + drawPathWithGradient( + path = path, + color = Environment.HUMIDITY.color, + height = height, + x1 = ((telemetries[index - 1].time - oldest.time).toFloat() / timeDiff) * width, + x2 = ((telemetries[first].time - oldest.time).toFloat() / timeDiff) * width + ) + } + + /* Air Quality */ + index = 0 + while (index < telemetries.size) { + first = index + val path = Path() + index = createPath( + telemetries = telemetries, + index = index, + path = path, + oldestTime = oldest.time, + timeRange = timeDiff, + width = width, + timeThreshold = selectedTime.timeThreshold() + ) { i -> + val telemetry = telemetries.getOrNull(i) ?: telemetries.last() + val ratio = (telemetry.environmentMetrics.iaq - min) / diff + val y = height - (ratio * height) + return@createPath y + } + drawPathWithGradient( + path = path, + color = Environment.IAQ.color, + height = height, + x1 = ((telemetries[index - 1].time - oldest.time).toFloat() / timeDiff) * width, + x2 = ((telemetries[first].time - oldest.time).toFloat() / timeDiff) * width + ) + } + } + } + YAxisLabels( + modifier = modifier.weight(weight = .1f), + graphColor, minValue = min, maxValue = max ) - - /* Plot Temperature and Relative Humidity */ - Canvas(modifier = modifier) { - val height = size.height - val width = size.width - 28.dp.toPx() - val spacePerEntry = (width - spacing) / telemetries.size - - /* Temperature */ - var lastTempX = 0f - val temperaturePath = Path().apply { - for (i in telemetries.indices) { - val envMetrics = telemetries[i].environmentMetrics - val nextEnvMetrics = - (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics - val leftRatio = (envMetrics.temperature - min) / diff - val rightRatio = (nextEnvMetrics.temperature - min) / diff - - val x1 = spacing + i * spacePerEntry - val y1 = height - (leftRatio * height) - - val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - (rightRatio * height) - if (i == 0) { - moveTo(x1, y1) - } - lastTempX = (x1 + x2) / 2f - quadraticTo( - x1, y1, lastTempX, (y1 + y2) / 2f - ) - } - } - - val fillPath = android.graphics.Path(temperaturePath.asAndroidPath()) - .asComposePath() - .apply { - lineTo(lastTempX, height) - lineTo(spacing, height) - close() - } - - drawPath( - path = fillPath, - brush = Brush.verticalGradient( - colors = listOf( - transparentTemperatureColor, - Color.Transparent - ), - endY = height - ), - ) - - drawPath( - path = temperaturePath, - color = Environment.TEMPERATURE.color, - style = Stroke( - width = 2.dp.toPx(), - cap = StrokeCap.Round - ) - ) - - /* Relative Humidity */ - var lastHumidityX = 0f - val humidityPath = Path().apply { - for (i in telemetries.indices) { - val envMetrics = telemetries[i].environmentMetrics - val nextEnvMetrics = - (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics - val leftRatio = (envMetrics.relativeHumidity - min) / diff - val rightRatio = (nextEnvMetrics.relativeHumidity - min) / diff - - val x1 = spacing + i * spacePerEntry - val y1 = height - (leftRatio * height) - - val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - (rightRatio * height) - if (i == 0) { - moveTo(x1, y1) - } - lastHumidityX = (x1 + x2) / 2f - quadraticTo( - x1, y1, lastHumidityX, (y1 + y2) / 2f - ) - } - } - - val fillHumidityPath = android.graphics.Path(humidityPath.asAndroidPath()) - .asComposePath() - .apply { - lineTo(lastHumidityX, height) - lineTo(spacing, height) - close() - } - - drawPath( - path = fillHumidityPath, - brush = Brush.verticalGradient( - colors = listOf( - transparentHumidityColor, - Color.Transparent - ), - endY = height - ), - ) - - drawPath( - path = humidityPath, - color = Environment.HUMIDITY.color, - style = Stroke( - width = 2.dp.toPx(), - cap = StrokeCap.Round - ) - ) - - /* Air Quality */ - var lastIaqX = 0f - val iaqPath = Path().apply { - for (i in telemetries.indices) { - val envMetrics = telemetries[i].environmentMetrics - val nextEnvMetrics = - (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics - val leftRatio = (envMetrics.iaq - min) / diff - val rightRatio = (nextEnvMetrics.iaq - min) / diff - - val x1 = spacing + i * spacePerEntry - val y1 = height - (leftRatio * height) - - val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - (rightRatio * height) - if (i == 0) { - moveTo(x1, y1) - } - lastIaqX = (x1 + x2) / 2f - quadraticTo( - x1, - y1, - lastIaqX, - (y1 + y2) / 2f - ) - } - } - - val fillIaqPath = android.graphics.Path(iaqPath.asAndroidPath()) - .asComposePath() - .apply { - lineTo(lastIaqX, height) - lineTo(spacing, height) - close() - } - drawPath( - path = fillIaqPath, - brush = Brush.verticalGradient( - colors = listOf( - transparentIAQColor, - Color.Transparent - ), - endY = height - ), - ) - - drawPath( - path = iaqPath, - color = Environment.IAQ.color, - style = Stroke( - width = 2.dp.toPx(), - cap = StrokeCap.Round - ) - ) - } } Spacer(modifier = Modifier.height(16.dp)) 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 b2ce995be..cad72d1ef 100644 --- a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt +++ b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt @@ -20,8 +20,15 @@ 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.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.drawscope.DrawContext +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp import com.geeksville.mesh.TelemetryProtos.Telemetry object GraphUtil { @@ -104,4 +111,38 @@ object GraphUtil { } return i } + + fun DrawScope.drawPathWithGradient( + path: Path, + color: Color, + height: Float, + x1: Float, + x2: Float + ) { + drawPath( + path = path, + color = color, + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + val fillPath = android.graphics.Path(path.asAndroidPath()) + .asComposePath() + .apply { + lineTo(x1, height) + lineTo(x2, height) + close() + } + drawPath( + path = fillPath, + brush = Brush.verticalGradient( + colors = listOf( + color.copy(alpha = 0.5f), + Color.Transparent + ), + endY = height + ), + ) + } }