From 2a50ea7f9494a205c6e94085d93ce2a81e2d9a9e Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 16 Jan 2026 11:01:52 +0100 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- CHANGELOG.md | 8 + .../ui/components/OptimizedLineChart.kt | 449 ++++++++++++++++++ .../ui/screens/charges/ChargeDetailScreen.kt | 130 +---- .../ui/screens/drives/DriveDetailScreen.kt | 151 +++--- 4 files changed, 534 insertions(+), 204 deletions(-) create mode 100644 app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e2fca1..48cd744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt new file mode 100644 index 0000000..3b82472 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt @@ -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, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + unit: String = "", + showZeroLine: Boolean = false, + fixedMinMax: Pair? = null, + timeLabels: List = 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(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, + 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, + fixedMinMax: Pair?, + 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, targetPoints: Int): List { + if (data.size <= targetPoints) return data + if (targetPoints < 3) return listOf(data.first(), data.last()) + + val result = mutableListOf() + + // 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, + 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) + } + } +} diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt index dfa057c..1a853b4 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt @@ -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): List { - 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): List { } } - 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) ?: "" } diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt index 7b98db6..7926d60 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -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, units: Units?) { +private fun SpeedChartCard(positions: List, units: Units?, timeLabels: List) { val speeds = positions.mapNotNull { it.speed?.toFloat() } if (speeds.size < 2) return @@ -602,6 +602,7 @@ private fun SpeedChartCard(positions: List, 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, units: Units?) { } @Composable -private fun PowerChartCard(positions: List) { +private fun PowerChartCard(positions: List, timeLabels: List) { val powers = positions.mapNotNull { it.power?.toFloat() } if (powers.size < 2) return @@ -619,12 +620,13 @@ private fun PowerChartCard(positions: List) { data = powers, color = MaterialTheme.colorScheme.tertiary, unit = "kW", - showZeroLine = true + showZeroLine = true, + timeLabels = timeLabels ) } @Composable -private fun BatteryChartCard(positions: List) { +private fun BatteryChartCard(positions: List, timeLabels: List) { val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() } if (batteryLevels.size < 2) return @@ -634,12 +636,13 @@ private fun BatteryChartCard(positions: List) { data = batteryLevels, color = MaterialTheme.colorScheme.secondary, unit = "%", - fixedMinMax = Pair(0f, 100f) + fixedMinMax = Pair(0f, 100f), + timeLabels = timeLabels ) } @Composable -private fun ElevationChartCard(positions: List) { +private fun ElevationChartCard(positions: List, timeLabels: List) { val elevations = positions.mapNotNull { it.elevation?.toFloat() } if (elevations.size < 2) return @@ -648,7 +651,8 @@ private fun ElevationChartCard(positions: List) { 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? = null, + timeLabels: List = 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): List { + 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 {