Fix/2100 graph labels (#2188)

Co-authored-by: Dane Evans <dane@goneepic.com>
This commit is contained in:
James Rich
2025-06-22 12:40:01 +00:00
committed by GitHub
parent 65ab9eb40d
commit 89ad4dc350
5 changed files with 257 additions and 51 deletions

View File

@@ -57,14 +57,16 @@ 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.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import java.text.DateFormat
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
}
@@ -190,7 +192,7 @@ fun TimeAxisOverlay(
Canvas(modifier = modifier) {
val height = size.height
val width = size.width - 28.dp.toPx()
val width = size.width
/* Cut out the time remaining in order to place the lines on the dot. */
val timeRemaining = oldest % timeInterval
@@ -247,18 +249,19 @@ fun TimeAxisOverlay(
@Composable
fun TimeLabels(
oldest: Int,
newest: Int
newest: Int,
) {
Row {
Text(
text = DATE_TIME_FORMAT.format(oldest * MS_PER_SEC),
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = DATE_TIME_FORMAT.format(newest * MS_PER_SEC),
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp

View File

@@ -50,6 +50,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.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -58,12 +59,14 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.common.components.BatteryInfo
import com.geeksville.mesh.ui.common.components.OptionLabel
import com.geeksville.mesh.ui.common.components.SlidingSelector
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.Orange
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
@@ -71,6 +74,7 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.plotPoint
import androidx.compose.ui.tooling.preview.PreviewLightDark
private enum class Device(val color: Color) {
BATTERY(Color.Green),
@@ -78,6 +82,10 @@ private enum class Device(val color: Color) {
AIR_UTIL(Color.Cyan)
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA = listOf(
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true),
LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color),
@@ -139,6 +147,7 @@ private fun DeviceMetricsChart(
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit
) {
val graphColor = MaterialTheme.colorScheme.onSurface
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) return
@@ -151,21 +160,33 @@ private fun DeviceMetricsChart(
}
val timeDiff = newest.time - oldest.time
TimeLabels(
oldest = oldest.time,
newest = newest.time
)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second,
)
Spacer(modifier = Modifier.height(16.dp))
Row {
Box(
contentAlignment = Alignment.TopStart,
@@ -252,7 +273,7 @@ private fun DeviceMetricsChart(
}
}
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
graphColor,
minValue = 0f,
maxValue = 100f
@@ -265,6 +286,34 @@ private fun DeviceMetricsChart(
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsChartPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries = List(20) { i ->
Telemetry.newBuilder()
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(80 - i)
.setVoltage(3.7f - i * 0.02f)
.setChannelUtilization(10f + i * 2)
.setAirUtilTx(5f + i)
.setUptimeSeconds(3600 + i * 300)
)
.build()
}
AppTheme {
DeviceMetricsChart(
modifier = Modifier.height(400.dp),
telemetries = telemetries,
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
promptInfoDialog = {}
)
}
}
@Composable
private fun DeviceMetricsCard(telemetry: Telemetry) {
val deviceMetrics = telemetry.deviceMetrics
@@ -321,3 +370,86 @@ private fun DeviceMetricsCard(telemetry: Telemetry) {
}
}
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsCardPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetry = Telemetry.newBuilder()
.setTime(now)
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(75)
.setVoltage(3.65f)
.setChannelUtilization(22.5f)
.setAirUtilTx(12.0f)
.setUptimeSeconds(7200)
)
.build()
AppTheme {
DeviceMetricsCard(telemetry = telemetry)
}
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsScreenPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries = List(24) { i ->
Telemetry.newBuilder()
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(85 - i * 2) // Battery decreases over time
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
.setUptimeSeconds(3600 + i * 3600) // Uptime increases by 1 hour each
)
.build()
}
AppTheme {
Surface {
Column {
var displayInfoDialog by remember { mutableStateOf(false) }
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes = listOf(
Pair(R.string.channel_utilization, R.string.ch_util_definition),
Pair(R.string.air_utilization, R.string.air_util_definition)
),
onDismiss = { displayInfoDialog = false }
)
}
DeviceMetricsChart(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f),
telemetries.reversed(),
TimeFrame.TWENTY_FOUR_HOURS,
promptInfoDialog = { displayInfoDialog = true }
)
SlidingSelector(
TimeFrame.entries.toList(),
TimeFrame.TWENTY_FOUR_HOURS,
onOptionSelected = { /* Preview only */ }
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
}
}
}
}
}

View File

@@ -47,8 +47,10 @@ 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.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -72,6 +74,20 @@ import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
import com.geeksville.mesh.util.UnitConversions.celsiusToFahrenheit
@Suppress("MagicNumber")
private enum class Environment(val color: Color) {
TEMPERATURE(Color.Red),
RELATIVE_HUMIDITY(Color.Blue),
BAROMETRIC_PRESSURE(Color.Green),
GAS_RESISTANCE(Color.Yellow),
IAQ(Color.Magenta)
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
// EnvironmentMetrics can have 1 or 2 Y-axis labels depending on whether barometric pressure is plotted
// We'll calculate this dynamically in the chart function
private val LEGEND_DATA_1 = listOf(
LegendData(
nameRes = R.string.temperature,
@@ -165,7 +181,6 @@ fun EnvironmentMetricsScreen(
}
}
/* TODO need to take the time to understand this. */
@SuppressLint("ConfigurationScreenWidthHeight")
@Suppress("LongMethod")
@Composable
@@ -182,9 +197,35 @@ private fun EnvironmentMetricsChart(
}
val (oldest, newest) = graphData.times
val timeDiff = newest - oldest
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
}
val shouldPlot = graphData.shouldPlot
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
val visibleWidthPx = screenWidth * chartWidthRatio
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(
oldest = oldest,
newest = newest
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second
)
Spacer(modifier = Modifier.height(16.dp))
@@ -196,18 +237,10 @@ private fun EnvironmentMetricsChart(
var min = rightMin
var diff = rightMax - rightMin
val scrollState = rememberScrollState()
val screenWidth = LocalConfiguration.current.screenWidthDp
val timeDiff = newest - oldest
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
}
val shouldPlot = graphData.shouldPlot
Row {
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Environment.BAROMETRIC_PRESSURE.color,
minValue = pressureMin,
maxValue = pressureMax
@@ -277,7 +310,7 @@ private fun EnvironmentMetricsChart(
}
}
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
graphColor,
minValue = rightMin,
maxValue = rightMax

View File

@@ -51,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.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -87,6 +88,10 @@ private enum class PowerChannel(@StringRes val strRes: Int) {
THREE(R.string.channel_3)
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA = listOf(
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true),
LegendData(nameRes = R.string.voltage, color = Power.VOLTAGE.color, isLine = true),
@@ -157,9 +162,29 @@ private fun PowerMetricsChart(
}
val timeDiff = newest.time - oldest.time
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(
oldest = oldest.time,
newest = newest.time
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second
)
Spacer(modifier = Modifier.height(16.dp))
@@ -168,15 +193,9 @@ private fun PowerMetricsChart(
val currentDiff = Power.CURRENT.difference()
val voltageDiff = Power.VOLTAGE.difference()
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
}
Row {
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Power.CURRENT.color,
minValue = Power.CURRENT.min,
maxValue = Power.CURRENT.max,
@@ -263,7 +282,7 @@ private fun PowerMetricsChart(
}
}
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Power.VOLTAGE.color,
minValue = Power.VOLTAGE.min,
maxValue = Power.VOLTAGE.max,

View File

@@ -48,7 +48,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -76,6 +77,11 @@ private enum class Metric(val color: Color, val min: Float, val max: Float) {
*/
fun difference() = max - min
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA = listOf(
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color)
@@ -148,9 +154,29 @@ private fun SignalMetricsChart(
}
val timeDiff = newest.rxTime - oldest.rxTime
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(
oldest = oldest.rxTime,
newest = newest.rxTime
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second
)
Spacer(modifier = Modifier.height(16.dp))
@@ -159,16 +185,9 @@ private fun SignalMetricsChart(
val snrDiff = Metric.SNR.difference()
val rssiDiff = Metric.RSSI.difference()
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()))
}
Row {
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.RSSI.color,
minValue = Metric.RSSI.min,
maxValue = Metric.RSSI.max,
@@ -221,7 +240,7 @@ private fun SignalMetricsChart(
}
}
YAxisLabels(
modifier = modifier.weight(weight = .1f),
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.SNR.color,
minValue = Metric.SNR.min,
maxValue = Metric.SNR.max,