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 ee32f285..50005391 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 @@ -46,7 +46,6 @@ 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.Path import androidx.compose.ui.graphics.StrokeCap @@ -66,6 +65,8 @@ 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.plotPoint +import com.geeksville.mesh.util.GraphUtil.createPath private val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) private const val MAX_PERCENT_VALUE = 100f @@ -195,61 +196,60 @@ private fun DeviceMetricsChart( 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] + 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 + /* x-value time */ + val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff + val x = xRatio * 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) - ) + /* Channel Utilization */ + plotPoint( + drawContext = drawContext, + color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], + radius = dataPointRadius, + x = x, + value = telemetry.deviceMetrics.channelUtilization, + divisor = MAX_PERCENT_VALUE + ) - /* 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) - } + /* Air Utilization Transmit */ + plotPoint( + drawContext = drawContext, + color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], + radius = dataPointRadius, + x = x, + value = telemetry.deviceMetrics.airUtilTx, + divisor = MAX_PERCENT_VALUE + ) } /* Battery Line */ - drawPath( - path = strokePath, - color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], - style = Stroke( - width = dataPointRadius, - cap = StrokeCap.Round + var index = 0 + while (index < telemetries.size) { + val path = Path() + index = createPath( + telemetries = telemetries, + index = index, + path = path, + oldestTime = oldest.time, + timeRange = timeDiff, + width = width + ) { i -> + val telemetry = telemetries.getOrNull(i) ?: telemetries.last() + val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + val y = height - (ratio * height) + return@createPath y + } + drawPath( + path = path, + color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], + style = Stroke( + width = dataPointRadius, + cap = StrokeCap.Round + ) ) - ) + } } } YAxisLabels( diff --git a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt new file mode 100644 index 00000000..817a09e0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.util + +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawContext +import com.geeksville.mesh.TelemetryProtos.Telemetry +import java.util.concurrent.TimeUnit + +private const val TIME_SEPARATION_THRESHOLD = 2L + +object GraphUtil { + + /** + * @param value Must be zero-scaled before passing. + * @param divisor The range for the data set. + */ + fun plotPoint( + drawContext: DrawContext, + color: Color, + radius: Float, + x: Float, + value: Float, + divisor: Float, + ) { + val height = drawContext.size.height + val ratio = value / divisor + val y = height - (ratio * height) + drawContext.canvas.drawCircle( + center = Offset(x, y), + radius = radius, + paint = androidx.compose.ui.graphics.Paint().apply { this.color = color } + ) + } + + /** + * Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` + * or the last point before a time separation between [Telemetry]s. + * + * @param telemetries data used to create the [Path] + * @param index current place in the [List] + * @param path [Path] that will be used to draw + * @param timeRange The time range for the data set + * @param width of the [DrawContext] + * @param calculateY (`index`) -> `y` coordinate + */ + fun createPath( + telemetries: List, + index: Int, + path: Path, + oldestTime: Int, + timeRange: Int, + width: Float, + calculateY: (Int) -> Float + ): Int { + var i = index + var isNewLine = true + with(path) { + while (i < telemetries.size) { + val telemetry = telemetries[i] + val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last() + + /* Check to see if we have a significant time break between telemetries. */ + if (nextTelemetry.time - telemetry.time > TimeUnit.HOURS.toSeconds(TIME_SEPARATION_THRESHOLD)) { + i++ + break + } + + val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange + val x1 = x1Ratio * width + val y1 = calculateY(i) + + val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange + val x2 = x2Ratio * width + val y2 = calculateY(i + 1) + + if (isNewLine || i == 0) { + isNewLine = false + moveTo(x1, y1) + } + + quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f) + i++ + } + } + return i + } +}