mirror of
https://github.com/vide/matedroid.git
synced 2026-01-20 00:03:17 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) ?: ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user