fix(charts): apply Vico 3.1.0 best-practice audit fixes (#5138)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-14 21:20:33 -05:00 committed by GitHub
parent 72b981f73b
commit a2763bdfeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 110 additions and 62 deletions

View file

@ -35,6 +35,7 @@ import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -79,6 +80,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
/** Minimum x-step (in seconds) to prevent the default GCD from producing a value of 1 with irregular timestamps. */
private const val MIN_X_STEP_SECONDS = 60.0
/**
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
* selection synchronization.
@ -100,43 +104,50 @@ fun GenericMetricChart(
onPointSelected: ((Double) -> Unit)? = null,
vicoScrollState: VicoScrollState = rememberVicoScrollState(),
) {
// Hoist zoom state above rememberCartesianChart so that the variable slot count
// from the vararg layers spread does not shift this remember call during recomposition
// (toggling legend chips changes the layer count, which corrupts the slot table).
val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content)
// Key on layer count so Compose rebuilds the entire subtree when legend chip toggles
// add/remove layers. rememberCartesianChart uses vararg internally, so changing the
// argument count without a key corrupts the slot table.
key(layers.size) {
val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content)
val markerVisibilityListener =
remember(onPointSelected) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
}
val markerVisibilityListener =
remember(onPointSelected) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
}
}
}
}
CartesianChartHost(
chart =
@Suppress("SpreadOperator")
rememberCartesianChart(
*layers.toTypedArray(),
startAxis = startAxis,
endAxis = endAxis,
bottomAxis = bottomAxis,
marker = marker,
markerVisibilityListener = markerVisibilityListener,
persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
fadingEdges = rememberFadingEdges(),
decorations = decorations,
),
modelProducer = modelProducer,
modifier = modifier,
scrollState = vicoScrollState,
zoomState = zoomState,
)
CartesianChartHost(
chart =
@Suppress("SpreadOperator")
rememberCartesianChart(
*layers.toTypedArray(),
startAxis = startAxis,
endAxis = endAxis,
bottomAxis = bottomAxis,
marker = marker,
markerVisibilityListener = markerVisibilityListener,
persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
fadingEdges = rememberFadingEdges(),
decorations = decorations,
// Telemetry timestamps arrive at irregular intervals. Without an explicit
// x-step, Vico computes the GCD of consecutive x-value differences which can
// be as small as 1 second, making the chart logically enormous. A 60-second
// floor keeps the internal slot count reasonable for any practical interval.
getXStep = { model -> maxOf(model.getXDeltaGcd(), MIN_X_STEP_SECONDS) },
),
modelProducer = modelProducer,
modifier = modifier,
scrollState = vicoScrollState,
zoomState = zoomState,
)
}
}
/**

View file

@ -57,7 +57,7 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
* **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)):
* - Default to thin lines **without** point markers to avoid clutter on dense timeseries.
* - Show a single dot only at the marker/cursor position (handled by [rememberMarker]).
* - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point.
* - Use `Interpolator.cubic()` for smooth monotone curves that won't overshoot between sparse points.
* - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data.
*/
@Suppress("TooManyFunctions")
@ -73,15 +73,21 @@ object ChartStyling {
*
* @param lineColor The color of the line
* @param lineWidth Width of the line in dp
* @param interpolator The line interpolation strategy. Defaults to monotone
* [cubic][LineCartesianLayer.Interpolator.cubic] which won't overshoot between sparse data points (unlike
* catmull-rom). Use [Sharp][LineCartesianLayer.Interpolator.Sharp] for discrete/integer metrics like hop counts.
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line =
LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
interpolator = LineCartesianLayer.Interpolator.catmullRom(),
)
fun createStyledLine(
lineColor: Color,
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
interpolator = interpolator,
)
/**
* Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The
@ -92,14 +98,18 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line {
fun createGradientLine(
lineColor: Color,
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
): LineCartesianLayer.Line {
val gradientBrush =
Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f)))
return LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)),
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
interpolator = LineCartesianLayer.Interpolator.catmullRom(),
interpolator = interpolator,
)
}
@ -110,8 +120,11 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createBoldLine(lineColor: Color): LineCartesianLayer.Line =
createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP)
fun createBoldLine(
lineColor: Color,
interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
): LineCartesianLayer.Line =
createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator)
/**
* Creates a subtle line suitable for secondary metrics that should not dominate the chart.
@ -131,7 +144,10 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
fun createDashedLine(
lineColor: Color,
interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
stroke =
LineCartesianLayer.LineStroke.Dashed(
@ -139,7 +155,7 @@ object ChartStyling {
dashLength = 6.dp,
gapLength = 3.dp,
),
interpolator = LineCartesianLayer.Interpolator.catmullRom(),
interpolator = interpolator,
)
/**

View file

@ -307,12 +307,13 @@ private fun DeviceMetricsChart(
}
}
val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) }
val leftLayer =
rememberConditionalLayer(
hasData = leftLayerSeriesStyles.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
rangeProvider = percentRangeProvider,
)
val rightLayer =

View file

@ -158,11 +158,11 @@ fun EnvironmentMetricsChart(
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
// Legend toggle state: tracks indices into allLegendData that are hidden
var hiddenIndices by remember { mutableStateOf(emptySet<Int>()) }
val hiddenMetrics =
remember(hiddenIndices, allLegendData) {
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
// Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering.
var hiddenMetrics by remember { mutableStateOf(emptySet<Environment>()) }
val hiddenIndices =
remember(hiddenMetrics, allLegendData) {
allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet()
}
val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) }
@ -233,6 +233,7 @@ fun EnvironmentMetricsChart(
},
)
val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) }
val layers = mutableListOf<LineCartesianLayer>()
if (showPressure && pressureData.isNotEmpty()) {
layers.add(
@ -244,7 +245,7 @@ fun EnvironmentMetricsChart(
verticalAxisPosition = Axis.Position.Vertical.Start,
// Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale,
// otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude.
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0),
rangeProvider = pressureRangeProvider,
),
)
}
@ -254,7 +255,7 @@ fun EnvironmentMetricsChart(
when (metric) {
Environment.RADIATION,
Environment.WIND_SPEED,
-> CartesianLayerRangeProvider.fixed(minY = 0.0)
-> CartesianLayerRangeProvider.auto()
else -> null
}
val lineStyle =
@ -310,7 +311,8 @@ fun EnvironmentMetricsChart(
modifier = Modifier.padding(top = 0.dp),
hiddenSet = hiddenIndices,
onToggle = { index ->
hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index
val metric = allLegendData.getOrNull(index)?.metricKey as? Environment ?: return@Legend
hiddenMetrics = if (metric in hiddenMetrics) hiddenMetrics - metric else hiddenMetrics + metric
},
)
}

View file

@ -141,11 +141,20 @@ private fun PaxMetricsChart(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(lineColor = bleColor),
ChartStyling.createGradientLine(lineColor = wifiColor),
ChartStyling.createBoldLine(lineColor = paxColor),
ChartStyling.createGradientLine(
lineColor = bleColor,
interpolator = LineCartesianLayer.Interpolator.Sharp,
),
ChartStyling.createGradientLine(
lineColor = wifiColor,
interpolator = LineCartesianLayer.Interpolator.Sharp,
),
ChartStyling.createBoldLine(
lineColor = paxColor,
interpolator = LineCartesianLayer.Interpolator.Sharp,
),
),
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
rangeProvider = CartesianLayerRangeProvider.auto(),
),
),
startAxis = VerticalAxis.rememberStart(label = axisLabel),

View file

@ -189,17 +189,26 @@ internal fun TracerouteMetricsChart(
val forwardLayer =
rememberConditionalLayer(
hasData = forwardData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createStyledLine(
forwardColor,
interpolator = LineCartesianLayer.Interpolator.Sharp,
),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
rangeProvider = CartesianLayerRangeProvider.auto(),
)
val returnLayer =
rememberConditionalLayer(
hasData = returnData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createDashedLine(returnColor, interpolator = LineCartesianLayer.Interpolator.Sharp),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
rangeProvider = CartesianLayerRangeProvider.auto(),
)
val rttLayer =