refactor(metrics): Prevent chart crashes with empty data (#4578)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-17 07:51:54 -06:00
committed by GitHub
parent 558cf77768
commit 85b3acd7ef
5 changed files with 305 additions and 189 deletions

View File

@@ -226,7 +226,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
)
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
@@ -258,59 +258,82 @@ private fun DeviceMetricsChart(
},
)
LaunchedEffect(telemetries) {
val batteryData = remember(telemetries) { telemetries.filter { it.device_metrics?.battery_level != null } }
val chUtilData = remember(telemetries) { telemetries.filter { it.device_metrics?.channel_utilization != null } }
val airUtilData = remember(telemetries) { telemetries.filter { it.device_metrics?.air_util_tx != null } }
val voltageData = remember(telemetries) { telemetries.filter { it.device_metrics?.voltage != null } }
val batteryStyle =
if (batteryData.isNotEmpty()) {
ChartStyling.createBoldLine(batteryColor, ChartStyling.MEDIUM_POINT_SIZE_DP)
} else {
null
}
val chUtilStyle =
if (chUtilData.isNotEmpty()) {
ChartStyling.createPointOnlyLine(chUtilColor, ChartStyling.LARGE_POINT_SIZE_DP)
} else {
null
}
val airUtilStyle =
if (airUtilData.isNotEmpty()) {
ChartStyling.createPointOnlyLine(airUtilColor, ChartStyling.LARGE_POINT_SIZE_DP)
} else {
null
}
val leftLayerSeriesStyles =
remember(batteryStyle, chUtilStyle, airUtilStyle) { listOfNotNull(batteryStyle, chUtilStyle, airUtilStyle) }
LaunchedEffect(batteryData, chUtilData, airUtilData, voltageData, leftLayerSeriesStyles) {
modelProducer.runTransaction {
/* Series for Left Axis (0-100%) */
lineSeries {
series(
x = telemetries.map { it.time ?: 0 },
y = telemetries.map { it.device_metrics?.battery_level ?: 0 },
)
val chUtilData = telemetries.filter { it.device_metrics?.channel_utilization != null }
series(
x = chUtilData.map { it.time ?: 0 },
y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f },
)
val airUtilData = telemetries.filter { it.device_metrics?.air_util_tx != null }
series(
x = airUtilData.map { it.time ?: 0 },
y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f },
)
if (leftLayerSeriesStyles.isNotEmpty()) {
lineSeries {
if (batteryData.isNotEmpty()) {
series(
x = batteryData.map { it.time ?: 0 },
y = batteryData.map { (it.device_metrics?.battery_level ?: 0).toFloat() },
)
}
if (chUtilData.isNotEmpty()) {
series(
x = chUtilData.map { it.time ?: 0 },
y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f },
)
}
if (airUtilData.isNotEmpty()) {
series(
x = airUtilData.map { it.time ?: 0 },
y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f },
)
}
}
}
/* Series for Right Axis (Voltage) */
lineSeries {
val voltageData = telemetries.filter { it.device_metrics?.voltage != null }
series(
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { it.device_metrics?.voltage ?: 0f },
)
if (voltageData.isNotEmpty()) {
lineSeries {
series(
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { it.device_metrics?.voltage ?: 0f },
)
}
}
}
}
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers =
listOf(
val leftLayer =
if (leftLayerSeriesStyles.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createBoldLine(
lineColor = batteryColor,
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
),
ChartStyling.createPointOnlyLine(
pointColor = chUtilColor,
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
),
ChartStyling.createPointOnlyLine(
pointColor = airUtilColor,
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
),
),
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
)
} else {
null
}
val rightLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
@@ -320,30 +343,49 @@ private fun DeviceMetricsChart(
),
),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers = layers,
startAxis =
if (leftLayer != null) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
valueFormatter = { _, value, _ -> "%.0f%%".format(value) },
)
} else {
null
},
endAxis =
if (rightLayer != null) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
)
} else {
null
},
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
labelRotationDegrees = 45f,
),
),
startAxis =
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
valueFormatter = { _, value, _ -> "%.0f%%".format(value) },
),
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp))
}

View File

@@ -129,16 +129,41 @@ fun EnvironmentMetricsChart(
}
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
LaunchedEffect(telemetries, graphData) {
val pressureData =
remember(telemetries) {
telemetries.filter {
val v = Environment.BAROMETRIC_PRESSURE.getValue(it)
it.time != 0 && v != null && !v.isNaN()
}
}
val otherMetrics =
remember(telemetries, shouldPlot) {
Environment.entries.filter { metric ->
metric != Environment.BAROMETRIC_PRESSURE &&
shouldPlot[metric.ordinal] &&
telemetries.any {
val v = metric.getValue(it)
it.time != 0 && v != null && !v.isNaN()
}
}
}
val otherMetricsData =
remember(telemetries, otherMetrics) {
otherMetrics.associateWith { metric ->
telemetries.filter {
val v = metric.getValue(it)
it.time != 0 && v != null && !v.isNaN()
}
}
}
LaunchedEffect(pressureData, otherMetricsData) {
modelProducer.runTransaction {
/* Pressure on its own layer/axis */
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) {
lineSeries {
val pressureData =
telemetries.filter {
val v = Environment.BAROMETRIC_PRESSURE.getValue(it)
it.time != 0 && v != null && !v.isNaN()
}
series(
x = pressureData.map { it.time },
y = pressureData.map { Environment.BAROMETRIC_PRESSURE.getValue(it)!! },
@@ -146,14 +171,10 @@ fun EnvironmentMetricsChart(
}
}
/* Everything else on the default axis */
Environment.entries.forEach { metric ->
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
otherMetrics.forEach { metric ->
val metricData = otherMetricsData[metric] ?: emptyList()
if (metricData.isNotEmpty()) {
lineSeries {
val metricData =
telemetries.filter {
val v = metric.getValue(it)
it.time != 0 && v != null && !v.isNaN()
}
series(x = metricData.map { it.time }, y = metricData.map { metric.getValue(it)!! })
}
}
@@ -171,7 +192,7 @@ fun EnvironmentMetricsChart(
)
val layers = mutableListOf<LineCartesianLayer>()
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) {
layers.add(
rememberLineCartesianLayer(
lineProvider =
@@ -185,31 +206,27 @@ fun EnvironmentMetricsChart(
),
)
}
Environment.entries.forEach { metric ->
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
layers.add(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
otherMetrics.forEach { metric ->
layers.add(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
)
}
verticalAxisPosition = Axis.Position.Vertical.End,
),
)
}
if (layers.isNotEmpty()) {
val otherMetricsPlotted =
Environment.entries.filter { it != Environment.BAROMETRIC_PRESSURE && shouldPlot[it.ordinal] }
val endAxisColor = if (otherMetricsPlotted.size == 1) otherMetricsPlotted.first().color else onSurfaceColor
val endAxisColor = if (otherMetrics.size == 1) otherMetrics.first().color else onSurfaceColor
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers = layers,
startAxis =
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color),
valueFormatter = { _, value, _ -> "%.0f hPa".format(value) },

View File

@@ -197,15 +197,17 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
// Prepare data for graph
val graphData =
paxMetrics
.map {
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
}
.sortedBy { it.first }
val totalSeries = graphData.map { it.first to (it.second + it.third) }
val bleSeries = graphData.map { it.first to it.second }
val wifiSeries = graphData.map { it.first to it.third }
remember(paxMetrics) {
paxMetrics
.map {
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
}
.sortedBy { it.first }
}
val totalSeries = remember(graphData) { graphData.map { it.first to (it.second + it.third) } }
val bleSeries = remember(graphData) { graphData.map { it.first to it.second } }
val wifiSeries = remember(graphData) { graphData.map { it.first to it.third } }
BaseMetricScreen(
onNavigateUp = onNavigateUp,

View File

@@ -212,67 +212,100 @@ private fun PowerMetricsChart(
},
)
LaunchedEffect(telemetries, selectedChannel) {
val currentData =
remember(telemetries, selectedChannel) {
telemetries.filter { !retrieveCurrent(selectedChannel, it).isNaN() }
}
val voltageData =
remember(telemetries, selectedChannel) {
telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
}
LaunchedEffect(currentData, voltageData) {
modelProducer.runTransaction {
lineSeries {
val currentData = telemetries.filter { !retrieveCurrent(selectedChannel, it).isNaN() }
series(
x = currentData.map { it.time ?: 0 },
y = currentData.map { retrieveCurrent(selectedChannel, it) },
)
if (currentData.isNotEmpty()) {
lineSeries {
series(
x = currentData.map { it.time ?: 0 },
y = currentData.map { retrieveCurrent(selectedChannel, it) },
)
}
}
lineSeries {
val voltageData = telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
series(
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { retrieveVoltage(selectedChannel, it) },
)
if (voltageData.isNotEmpty()) {
lineSeries {
series(
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { retrieveVoltage(selectedChannel, it) },
)
}
}
}
}
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers =
listOf(
val currentLayer =
if (currentData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
)
} else {
null
}
val voltageLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers = layers,
startAxis =
if (currentData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
)
} else {
null
},
endAxis =
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
)
} else {
null
},
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
),
startAxis =
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
),
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}

View File

@@ -149,7 +149,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
)
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
@@ -162,24 +162,24 @@ private fun SignalMetricsChart(
if (meshPackets.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
LaunchedEffect(meshPackets) {
val rssiData = remember(meshPackets) { meshPackets.filter { (it.rx_rssi ?: 0) != 0 } }
val snrData = remember(meshPackets) { meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) } }
LaunchedEffect(rssiData, snrData) {
modelProducer.runTransaction {
/* Use separate lineSeries calls to associate them with different vertical axes */
lineSeries {
val rssiData = meshPackets.filter { (it.rx_rssi ?: 0) != 0 }
series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 })
if (rssiData.isNotEmpty()) {
/* Use separate lineSeries calls to associate them with different vertical axes */
lineSeries { series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 }) }
}
lineSeries {
val snrData = meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) }
series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f })
if (snrData.isNotEmpty()) {
lineSeries { series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f }) }
}
}
}
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
@@ -192,48 +192,70 @@ private fun SignalMetricsChart(
},
)
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers =
listOf(
val rssiLayer =
if (rssiData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
)
} else {
null
}
val snrLayer =
if (snrData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
layers = layers,
startAxis =
if (rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
)
} else {
null
},
endAxis =
if (snrData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
)
} else {
null
},
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
),
startAxis =
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
),
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}