perf(charts): optimize line charts with LTTB downsampling for smooth scrolling (#59)

* perf(charts): optimize line charts with LTTB downsampling for smooth scrolling

Long trips (>100km) and charging sessions could have hundreds or thousands
of data points, causing scroll stutter when rendering charts.

Changes:
- Add OptimizedLineChart component with LTTB (Largest Triangle Three Buckets)
  downsampling algorithm
- Reduce data points to max 150 while preserving visual shape
- Use Path-based drawing instead of individual line segments
- Cache computed values with remember() to prevent recalculation during scroll
- Support tap-to-show-tooltip functionality
- Support time labels on X-axis for charge detail charts

Both DriveDetailScreen and ChargeDetailScreen now use the optimized component.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(charts): add proper axis labels following chart guidelines

Apply chart guidelines from CLAUDE.md:

Y-axis labels:
- Show 4 labels at: 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
- Skip the minimum (0%) label at top

X-axis time labels:
- Show 5 labels at: start (0%), 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
- Added time label extraction for DriveDetailScreen charts
- Updated ChargeDetailScreen to use 5 labels instead of 4

Both screens now follow the standardized chart guidelines for consistent UX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2026-01-16 11:01:52 +01:00
committed by GitHub
parent 2a6c5a6c78
commit 2a50ea7f94
4 changed files with 534 additions and 204 deletions

View File

@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Drive Details**: Charts now use optimized rendering with data downsampling (LTTB algorithm) for smooth scrolling on long trips
- **Drive Details**: Charts now display time labels on X-axis (start, 1st quarter, half, 3rd quarter, end)
- **Drive Details**: Charts Y-axis now shows 4 labels at quarter intervals (25%, 50%, 75%, 100%)
- **Charge Details**: Charts now use optimized rendering with data downsampling (LTTB algorithm) for smooth scrolling on long charging sessions
- **Charge Details**: Charts X-axis now shows 5 time labels (start, 1st quarter, half, 3rd quarter, end)
- **Charge Details**: Charts Y-axis now shows 4 labels at quarter intervals (25%, 50%, 75%, 100%)
### Added
- **Drive Details**: Weather Along the Way - shows historical weather conditions along your drive route
- Uses Open-Meteo API to fetch historical weather data for points along the route

View File

@@ -0,0 +1,449 @@
package com.matedroid.ui.components
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Maximum number of data points to display on the chart.
* Higher values mean more detail but slower rendering.
* 150 points is a good balance between visual quality and performance.
*/
private const val MAX_DISPLAY_POINTS = 150
/**
* An optimized line chart component designed for smooth scrolling performance.
*
* Performance optimizations:
* 1. Data downsampling using LTTB algorithm - reduces points while preserving visual shape
* 2. Cached computations using remember - prevents recalculation on every frame
* 3. Path-based drawing - single draw call instead of many line segments
* 4. Minimal text drawing - labels drawn efficiently
*/
@Composable
fun OptimizedLineChart(
data: List<Float>,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
unit: String = "",
showZeroLine: Boolean = false,
fixedMinMax: Pair<Float, Float>? = null,
timeLabels: List<String> = emptyList(),
convertValue: (Float) -> Float = { it }
) {
if (data.size < 2) return
val surfaceColor = MaterialTheme.colorScheme.onSurface
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
// Cache all computed values to avoid recalculation during scroll
val chartData = remember(data, fixedMinMax, convertValue) {
prepareChartData(data, fixedMinMax, convertValue)
}
// State for tooltip on tap
var selectedPoint by remember { mutableStateOf<SelectedPoint?>(null) }
// Calculate heights
val chartHeightDp = 120.dp
val timeLabelHeightDp = if (timeLabels.isNotEmpty()) 20.dp else 0.dp
val totalHeightDp = chartHeightDp + timeLabelHeightDp
Box(modifier = modifier) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(totalHeightDp)
.pointerInput(chartData) {
detectTapGestures { offset ->
val width = size.width.toFloat()
val chartHeightPx = chartHeightDp.toPx()
val points = chartData.displayPoints
if (points.isEmpty()) return@detectTapGestures
// Only respond to taps in the chart area (not the time labels)
if (offset.y > chartHeightPx) return@detectTapGestures
// Find closest point to tap
val stepX = width / (points.size - 1).coerceAtLeast(1)
val tappedIndex = ((offset.x / stepX).roundToInt()).coerceIn(0, points.lastIndex)
val pointValue = points[tappedIndex]
val pointX = tappedIndex * stepX
val pointY = chartHeightPx * (1 - (pointValue - chartData.minValue) / chartData.range)
selectedPoint = if (selectedPoint?.index == tappedIndex) {
null // Toggle off
} else {
SelectedPoint(tappedIndex, pointValue, Offset(pointX, pointY))
}
}
}
) {
val width = size.width
val chartHeightPx = chartHeightDp.toPx()
val timeLabelHeightPx = timeLabelHeightDp.toPx()
// Draw grid lines
drawGridLines(gridColor, width, chartHeightPx)
// Draw zero line if needed (for power chart with negative values)
if (showZeroLine && chartData.minValue < 0 && chartData.maxValue > 0) {
drawZeroLine(surfaceColor, chartData, width, chartHeightPx)
}
// Draw the cached path
drawPath(
path = chartData.createPath(width, chartHeightPx),
color = color,
style = Stroke(width = 2.5f)
)
// Draw Y-axis labels
drawYAxisLabels(surfaceColor, chartData, unit, chartHeightPx)
// Draw time labels if provided (5 labels: start, 1st quarter, half, 3rd quarter, end)
if (timeLabels.size == 5) {
drawTimeLabels(surfaceColor, timeLabels, width, chartHeightPx, timeLabelHeightPx)
}
// Draw selected point indicator
selectedPoint?.let { point ->
drawCircle(
color = color,
radius = 6.dp.toPx(),
center = point.position
)
drawCircle(
color = Color.White,
radius = 3.dp.toPx(),
center = point.position
)
}
}
// Tooltip overlay
selectedPoint?.let { point ->
TooltipOverlay(
value = point.value,
unit = unit,
position = point.position,
modifier = Modifier.fillMaxSize()
)
}
}
}
/**
* Holds pre-computed chart data for efficient rendering
*/
private data class ChartData(
val displayPoints: List<Float>,
val minValue: Float,
val maxValue: Float,
val range: Float
) {
/**
* Creates a Path for the line chart.
* Using Path is more efficient than drawing individual line segments.
*/
fun createPath(width: Float, height: Float): Path {
val path = Path()
if (displayPoints.size < 2) return path
val stepX = width / (displayPoints.size - 1).coerceAtLeast(1)
displayPoints.forEachIndexed { index, value ->
val x = index * stepX
val y = height * (1 - (value - minValue) / range)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
return path
}
}
private data class SelectedPoint(
val index: Int,
val value: Float,
val position: Offset
)
/**
* Prepares chart data with downsampling if needed
*/
private fun prepareChartData(
data: List<Float>,
fixedMinMax: Pair<Float, Float>?,
convertValue: (Float) -> Float
): ChartData {
// Convert values
val convertedData = data.map { convertValue(it) }
// Downsample if necessary
val displayPoints = if (convertedData.size > MAX_DISPLAY_POINTS) {
downsampleLTTB(convertedData, MAX_DISPLAY_POINTS)
} else {
convertedData
}
// Calculate min/max
val minValue = fixedMinMax?.first ?: displayPoints.minOrNull() ?: 0f
val maxValue = fixedMinMax?.second ?: displayPoints.maxOrNull() ?: 1f
val range = (maxValue - minValue).coerceAtLeast(1f)
return ChartData(displayPoints, minValue, maxValue, range)
}
/**
* Largest Triangle Three Buckets (LTTB) downsampling algorithm.
* This algorithm reduces the number of data points while preserving the visual
* shape of the line chart. It's specifically designed for time series visualization.
*
* The algorithm works by:
* 1. Always keeping the first and last points
* 2. Dividing remaining points into buckets
* 3. For each bucket, selecting the point that forms the largest triangle
* with the previous selected point and the average of the next bucket
*
* Reference: Sveinn Steinarsson, "Downsampling Time Series for Visual Representation"
*/
private fun downsampleLTTB(data: List<Float>, targetPoints: Int): List<Float> {
if (data.size <= targetPoints) return data
if (targetPoints < 3) return listOf(data.first(), data.last())
val result = mutableListOf<Float>()
// Always include the first point
result.add(data.first())
// Calculate bucket size
val bucketSize = (data.size - 2).toFloat() / (targetPoints - 2)
var prevSelectedIndex = 0
for (i in 0 until targetPoints - 2) {
// Calculate bucket boundaries
val bucketStart = ((i * bucketSize) + 1).toInt()
val bucketEnd = (((i + 1) * bucketSize) + 1).toInt().coerceAtMost(data.size - 1)
// Calculate average point of the next bucket (for triangle calculation)
val nextBucketStart = bucketEnd
val nextBucketEnd = (((i + 2) * bucketSize) + 1).toInt().coerceAtMost(data.size)
var avgX = 0f
var avgY = 0f
var count = 0
for (j in nextBucketStart until nextBucketEnd) {
avgX += j.toFloat()
avgY += data[j]
count++
}
if (count > 0) {
avgX /= count
avgY /= count
} else {
avgX = nextBucketStart.toFloat()
avgY = data.getOrElse(nextBucketStart) { data.last() }
}
// Find the point in current bucket that creates the largest triangle
val prevX = prevSelectedIndex.toFloat()
val prevY = data[prevSelectedIndex]
var maxArea = -1f
var selectedIndex = bucketStart
for (j in bucketStart until bucketEnd) {
// Calculate triangle area using the cross product formula
val area = abs(
(prevX - avgX) * (data[j] - prevY) -
(prevX - j.toFloat()) * (avgY - prevY)
) * 0.5f
if (area > maxArea) {
maxArea = area
selectedIndex = j
}
}
result.add(data[selectedIndex])
prevSelectedIndex = selectedIndex
}
// Always include the last point
result.add(data.last())
return result
}
private fun DrawScope.drawGridLines(gridColor: Color, width: Float, height: Float) {
val gridLineCount = 4
for (i in 0..gridLineCount) {
val y = height * i / gridLineCount
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1f
)
}
}
private fun DrawScope.drawZeroLine(surfaceColor: Color, chartData: ChartData, width: Float, height: Float) {
val zeroY = height * (1 - (0f - chartData.minValue) / chartData.range)
drawLine(
color = surfaceColor.copy(alpha = 0.5f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 2f
)
}
/**
* Draws Y-axis labels at 4 positions: 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
* Following the chart guidelines from CLAUDE.md
*/
private fun DrawScope.drawYAxisLabels(
surfaceColor: Color,
chartData: ChartData,
unit: String,
height: Float
) {
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}
// 4 labels at: 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
// These correspond to grid lines 1, 2, 3, 4 (skipping 0 which is the top/max)
val labelPositions = listOf(1, 2, 3, 4) // Skip position 0 (top)
val gridLineCount = 4
for (i in labelPositions) {
val y = height * i / gridLineCount
val value = chartData.maxValue - (chartData.range * i / gridLineCount)
val label = "%.0f".format(value) + " $unit"
// Position the label: bottom label above line, others centered on line
val textY = when (i) {
gridLineCount -> y - 4f // Bottom label above line
else -> y + textPaint.textSize / 3 // Others centered
}
drawText(label, 8f, textY, textPaint)
}
}
}
/**
* Draws X-axis time labels at 5 positions: start (0%), 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
* Following the chart guidelines from CLAUDE.md
*/
private fun DrawScope.drawTimeLabels(
surfaceColor: Color,
timeLabels: List<String>,
width: Float,
chartHeight: Float,
timeLabelHeight: Float
) {
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}
val timeY = chartHeight + timeLabelHeight - 4f
// 5 positions at 0%, 25%, 50%, 75%, 100%
val positions = listOf(0f, width * 0.25f, width * 0.5f, width * 0.75f, width)
timeLabels.forEachIndexed { index, label ->
if (label.isNotEmpty()) {
val textWidth = textPaint.measureText(label)
val x = when (index) {
0 -> 0f // Left aligned (start)
4 -> positions[index] - textWidth // Right aligned (end)
else -> positions[index] - textWidth / 2 // Center aligned (quarters)
}
drawText(label, x.coerceAtLeast(0f), timeY, textPaint)
}
}
}
}
@Composable
private fun TooltipOverlay(
value: Float,
unit: String,
position: Offset,
modifier: Modifier = Modifier
) {
// Simple tooltip using Canvas text drawing for performance
Canvas(modifier = modifier) {
val text = "%.1f $unit".format(value)
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
color = android.graphics.Color.WHITE
textSize = 32f
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val bgPaint = Paint().apply {
color = android.graphics.Color.argb(200, 50, 50, 50)
isAntiAlias = true
}
val textWidth = textPaint.measureText(text)
val padding = 16f
val tooltipY = (position.y - 40f).coerceAtLeast(50f)
val tooltipX = position.x.coerceIn(textWidth / 2 + padding, size.width - textWidth / 2 - padding)
// Draw background
drawRoundRect(
tooltipX - textWidth / 2 - padding,
tooltipY - 30f,
tooltipX + textWidth / 2 + padding,
tooltipY + 8f,
12f,
12f,
bgPaint
)
// Draw text
drawText(text, tooltipX, tooltipY, textPaint)
}
}
}

View File

@@ -1,9 +1,7 @@
package com.matedroid.ui.screens.charges
import android.content.Intent
import android.graphics.Paint
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -54,9 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -68,6 +64,7 @@ import com.matedroid.data.api.models.ChargeDetail
import com.matedroid.data.api.models.ChargePoint
import com.matedroid.data.api.models.Units
import com.matedroid.domain.model.UnitFormatter
import com.matedroid.ui.components.OptimizedLineChart
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
@@ -716,109 +713,16 @@ private fun ChartCard(
)
}
val convertedData = data.map { convertValue(it) }
val minValue = fixedMinMax?.first ?: convertedData.minOrNull() ?: 0f
val maxValue = fixedMinMax?.second ?: convertedData.maxOrNull() ?: 1f
val range = (maxValue - minValue).coerceAtLeast(1f)
val surfaceColor = MaterialTheme.colorScheme.onSurface
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
// Chart area height (leaves room for time labels)
val chartHeight = 120.dp
val timeLabelHeight = if (timeLabels.isNotEmpty()) 20.dp else 0.dp
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(chartHeight + timeLabelHeight)
) {
val width = size.width
val chartHeightPx = chartHeight.toPx()
val stepX = width / (convertedData.size - 1).coerceAtLeast(1)
// Draw grid lines
val gridLineCount = 4
for (i in 0..gridLineCount) {
val y = chartHeightPx * i / gridLineCount
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1f
)
}
// Draw zero line if needed
if (showZeroLine && minValue < 0 && maxValue > 0) {
val zeroY = chartHeightPx * (1 - (0f - minValue) / range)
drawLine(
color = surfaceColor.copy(alpha = 0.5f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 2f
)
}
// Draw the line chart
if (convertedData.size >= 2) {
for (i in 0 until convertedData.size - 1) {
val x1 = i * stepX
val x2 = (i + 1) * stepX
val y1 = chartHeightPx * (1 - (convertedData[i] - minValue) / range)
val y2 = chartHeightPx * (1 - (convertedData[i + 1] - minValue) / range)
drawLine(
color = color,
start = Offset(x1, y1),
end = Offset(x2, y2),
strokeWidth = 2.5f
)
}
}
// Draw Y-axis labels for all grid lines and time labels
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
this.color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}
for (i in 0..gridLineCount) {
val y = chartHeightPx * i / gridLineCount
val value = maxValue - (range * i / gridLineCount)
val label = "%.0f".format(value) + " $unit"
// Position the label: top labels below line, bottom labels above line
val textY = when (i) {
0 -> y + textPaint.textSize + 2f
gridLineCount -> y - 4f
else -> y + textPaint.textSize / 3
}
drawText(label, 8f, textY, textPaint)
}
// Draw time labels on X axis (4 labels)
if (timeLabels.size == 4) {
val timeY = chartHeightPx + timeLabelHeight.toPx() - 4f
val positions = listOf(0f, width / 3f, width * 2f / 3f, width)
timeLabels.forEachIndexed { index, label ->
if (label.isNotEmpty()) {
val textWidth = textPaint.measureText(label)
val x = when (index) {
0 -> 0f // Left aligned
3 -> positions[index] - textWidth // Right aligned
else -> positions[index] - textWidth / 2 // Center aligned
}
drawText(label, x.coerceAtLeast(0f), timeY, textPaint)
}
}
}
}
}
OptimizedLineChart(
data = data,
color = color,
unit = unit,
showZeroLine = showZeroLine,
fixedMinMax = fixedMinMax,
timeLabels = timeLabels,
convertValue = convertValue,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@@ -845,11 +749,12 @@ private fun ChargeTypeBadge(isDcCharge: Boolean) {
}
/**
* Extract 4 time labels from charge points for X axis display.
* Returns list of 4 time strings at 0%, 33%, 67%, and 100% positions.
* Extract 5 time labels from charge points for X axis display.
* Returns list of 5 time strings at 0%, 25%, 50%, 75%, and 100% positions.
* Following the chart guidelines: start, 1st quarter, half, 3rd quarter, end.
*/
private fun extractTimeLabels(chargePoints: List<ChargePoint>): List<String> {
if (chargePoints.isEmpty()) return listOf("", "", "", "")
if (chargePoints.isEmpty()) return listOf("", "", "", "", "")
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val times = chargePoints.mapNotNull { point ->
@@ -867,9 +772,10 @@ private fun extractTimeLabels(chargePoints: List<ChargePoint>): List<String> {
}
}
if (times.isEmpty()) return listOf("", "", "", "")
if (times.isEmpty()) return listOf("", "", "", "", "")
val indices = listOf(0, times.size / 3, times.size * 2 / 3, times.size - 1)
// 5 positions: start (0%), 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
val indices = listOf(0, times.size / 4, times.size / 2, times.size * 3 / 4, times.size - 1)
return indices.map { idx ->
times.getOrNull(idx.coerceIn(0, times.size - 1))?.format(timeFormatter) ?: ""
}

View File

@@ -1,10 +1,8 @@
package com.matedroid.ui.screens.drives
import android.content.Intent
import android.graphics.Color as AndroidColor
import android.graphics.Paint
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -57,9 +55,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -72,6 +68,7 @@ import com.matedroid.data.api.models.DrivePosition
import com.matedroid.data.api.models.Units
import com.matedroid.data.repository.WeatherPoint
import com.matedroid.domain.model.UnitFormatter
import com.matedroid.ui.components.OptimizedLineChart
import com.matedroid.ui.theme.CarColorPalettes
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@@ -255,11 +252,14 @@ private fun DriveDetailContent(
// Charts
if (!detail.positions.isNullOrEmpty() && detail.positions.size > 2) {
SpeedChartCard(positions = detail.positions, units = units)
PowerChartCard(positions = detail.positions)
BatteryChartCard(positions = detail.positions)
// Extract time labels for X axis (5 labels: start, 1st quarter, half, 3rd quarter, end)
val timeLabels = extractTimeLabels(detail.positions)
SpeedChartCard(positions = detail.positions, units = units, timeLabels = timeLabels)
PowerChartCard(positions = detail.positions, timeLabels = timeLabels)
BatteryChartCard(positions = detail.positions, timeLabels = timeLabels)
if (detail.positions.any { it.elevation != null && it.elevation != 0 }) {
ElevationChartCard(positions = detail.positions)
ElevationChartCard(positions = detail.positions, timeLabels = timeLabels)
}
}
}
@@ -592,7 +592,7 @@ private fun StatItemView(
}
@Composable
private fun SpeedChartCard(positions: List<DrivePosition>, units: Units?) {
private fun SpeedChartCard(positions: List<DrivePosition>, units: Units?, timeLabels: List<String>) {
val speeds = positions.mapNotNull { it.speed?.toFloat() }
if (speeds.size < 2) return
@@ -602,6 +602,7 @@ private fun SpeedChartCard(positions: List<DrivePosition>, units: Units?) {
data = speeds,
color = MaterialTheme.colorScheme.primary,
unit = UnitFormatter.getSpeedUnit(units),
timeLabels = timeLabels,
convertValue = { value ->
if (units?.isImperial == true) (value * 0.621371f) else value
}
@@ -609,7 +610,7 @@ private fun SpeedChartCard(positions: List<DrivePosition>, units: Units?) {
}
@Composable
private fun PowerChartCard(positions: List<DrivePosition>) {
private fun PowerChartCard(positions: List<DrivePosition>, timeLabels: List<String>) {
val powers = positions.mapNotNull { it.power?.toFloat() }
if (powers.size < 2) return
@@ -619,12 +620,13 @@ private fun PowerChartCard(positions: List<DrivePosition>) {
data = powers,
color = MaterialTheme.colorScheme.tertiary,
unit = "kW",
showZeroLine = true
showZeroLine = true,
timeLabels = timeLabels
)
}
@Composable
private fun BatteryChartCard(positions: List<DrivePosition>) {
private fun BatteryChartCard(positions: List<DrivePosition>, timeLabels: List<String>) {
val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() }
if (batteryLevels.size < 2) return
@@ -634,12 +636,13 @@ private fun BatteryChartCard(positions: List<DrivePosition>) {
data = batteryLevels,
color = MaterialTheme.colorScheme.secondary,
unit = "%",
fixedMinMax = Pair(0f, 100f)
fixedMinMax = Pair(0f, 100f),
timeLabels = timeLabels
)
}
@Composable
private fun ElevationChartCard(positions: List<DrivePosition>) {
private fun ElevationChartCard(positions: List<DrivePosition>, timeLabels: List<String>) {
val elevations = positions.mapNotNull { it.elevation?.toFloat() }
if (elevations.size < 2) return
@@ -648,7 +651,8 @@ private fun ElevationChartCard(positions: List<DrivePosition>) {
icon = Icons.Default.Landscape,
data = elevations,
color = Color(0xFF8B4513), // Brown color for terrain
unit = "m"
unit = "m",
timeLabels = timeLabels
)
}
@@ -661,6 +665,7 @@ private fun ChartCard(
unit: String,
showZeroLine: Boolean = false,
fixedMinMax: Pair<Float, Float>? = null,
timeLabels: List<String> = emptyList(),
convertValue: (Float) -> Float = { it }
) {
Card(
@@ -690,89 +695,51 @@ private fun ChartCard(
)
}
val convertedData = data.map { convertValue(it) }
val minValue = fixedMinMax?.first ?: convertedData.minOrNull() ?: 0f
val maxValue = fixedMinMax?.second ?: convertedData.maxOrNull() ?: 1f
val range = (maxValue - minValue).coerceAtLeast(1f)
OptimizedLineChart(
data = data,
color = color,
unit = unit,
showZeroLine = showZeroLine,
fixedMinMax = fixedMinMax,
timeLabels = timeLabels,
convertValue = convertValue,
modifier = Modifier.fillMaxWidth()
)
}
}
}
val surfaceColor = MaterialTheme.colorScheme.onSurface
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
/**
* Extract 5 time labels from drive positions for X axis display.
* Returns list of 5 time strings at 0%, 25%, 50%, 75%, and 100% positions.
* Following the chart guidelines: start, 1st quarter, half, 3rd quarter, end.
*/
private fun extractTimeLabels(positions: List<DrivePosition>): List<String> {
if (positions.isEmpty()) return listOf("", "", "", "", "")
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
val width = size.width
val height = size.height
val stepX = width / (convertedData.size - 1).coerceAtLeast(1)
// Draw grid lines
val gridLineCount = 4
for (i in 0..gridLineCount) {
val y = height * i / gridLineCount
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1f
)
}
// Draw zero line if needed (for power chart)
if (showZeroLine && minValue < 0 && maxValue > 0) {
val zeroY = height * (1 - (0f - minValue) / range)
drawLine(
color = surfaceColor.copy(alpha = 0.5f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 2f
)
}
// Draw the line chart
if (convertedData.size >= 2) {
for (i in 0 until convertedData.size - 1) {
val x1 = i * stepX
val x2 = (i + 1) * stepX
val y1 = height * (1 - (convertedData[i] - minValue) / range)
val y2 = height * (1 - (convertedData[i + 1] - minValue) / range)
drawLine(
color = color,
start = Offset(x1, y1),
end = Offset(x2, y2),
strokeWidth = 2.5f
)
}
}
// Draw Y-axis labels for all grid lines
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
this.color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}
for (i in 0..gridLineCount) {
val y = height * i / gridLineCount
val value = maxValue - (range * i / gridLineCount)
val label = "%.0f".format(value) + " $unit"
// Position the label: top labels below line, bottom labels above line
val textY = when (i) {
0 -> y + textPaint.textSize + 2f
gridLineCount -> y - 4f
else -> y + textPaint.textSize / 3
}
drawText(label, 8f, textY, textPaint)
}
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val times = positions.mapNotNull { position ->
position.date?.let { dateStr ->
try {
val dateTime = try {
OffsetDateTime.parse(dateStr).toLocalDateTime()
} catch (e: DateTimeParseException) {
LocalDateTime.parse(dateStr.replace("Z", ""))
}
dateTime
} catch (e: Exception) {
null
}
}
}
if (times.isEmpty()) return listOf("", "", "", "", "")
// 5 positions: start (0%), 1st quarter (25%), half (50%), 3rd quarter (75%), end (100%)
val indices = listOf(0, times.size / 4, times.size / 2, times.size * 3 / 4, times.size - 1)
return indices.map { idx ->
times.getOrNull(idx.coerceIn(0, times.size - 1))?.format(timeFormatter) ?: ""
}
}
private fun formatDateTime(dateStr: String?): String {