Colorize headwind forecast line graph and the area beneath it (#155)
All checks were successful
Build / build (push) Successful in 5m19s

This commit is contained in:
timklge 2025-06-12 23:32:26 +02:00 committed by GitHub
parent 87a244ef27
commit 2378c944e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 168 additions and 63 deletions

View File

@ -1,8 +1,10 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Canvas
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.core.graphics.createBitmap
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
@ -71,7 +73,8 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean
isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line>
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
@ -265,8 +268,10 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
data,
settingsAndProfile.isImperialTemperature,
upcomingRoute,
config.preview
config.preview,
context
)
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)

View File

@ -1,5 +1,6 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
@ -9,7 +10,8 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean
isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line> {
val precipitationPoints = lineData.map { data ->
if (isImperial) { // Convert mm to inches

View File

@ -16,6 +16,7 @@ import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamDatatypeIsVisible
@ -199,14 +200,10 @@ class TailwindAndRideSpeedDataType(
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue} ${windSpeedUserUnit.roundToInt()}${gustSpeedAddon}"
}
var dayColor = Color(ContextCompat.getColor(context, R.color.black))
var nightColor = Color(ContextCompat.getColor(context, R.color.white))
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val dayColor = interpolateWindColor(windSpeedInKmh, false, context)
val nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(

View File

@ -1,5 +1,6 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
@ -9,7 +10,8 @@ class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphFo
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean
isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line> {
val linePoints = lineData.map { data ->
if (isImperial) {

View File

@ -1,12 +1,16 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Color
import android.util.Log
import androidx.compose.ui.graphics.toArgb
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.lerpWeather
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.ceil
@ -23,7 +27,8 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean
isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line> {
val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
@ -47,16 +52,21 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) {
return@mapNotNull LineGraphBuilder.DataPoint(i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()), (-10f) + (20f * kotlin.random.Random.nextFloat()))
// Use a sine wave for headwind preview speed
val headwindSpeed = 10f * kotlin.math.sin(i * Math.PI * 2 / HEADWIND_SAMPLE_COUNT).toFloat()
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
return@mapNotNull LineGraphBuilder.DataPoint(x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeed)
}
if (upcomingRoute == null) return@mapNotNull null
val beforeLineData = lineData.getOrNull(floor(lineData.size * t).toInt()) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil(lineData.size * t).toInt()) ?: lineData.lastOrNull()
val beforeLineData = lineData.getOrNull(floor((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null
|| afterLineData.distance == null) return@mapNotNull null
|| afterLineData.distance == null || beforeLineData == afterLineData) return@mapNotNull null
val dt = remap(t.toFloat(),
floor(lineData.size * t).toFloat() / lineData.size,
@ -95,7 +105,10 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
headwindSpeed * 3.6 // Convert m/s to km/h
}
LineGraphBuilder.DataPoint(i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()), headwindSpeedInUserUnit.toFloat())
LineGraphBuilder.DataPoint(
x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeedInUserUnit.toFloat()
)
}
} else {
emptyList()
@ -106,34 +119,40 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
}
return buildSet {
add(LineGraphBuilder.Line(
dataPoints = windPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
))
add(LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.DKGRAY,
color = Color.DKGRAY,
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph",
))
add(LineGraphBuilder.Line(
dataPoints = windPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
))
if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line(
dataPoints = headwindPoints,
color = android.graphics.Color.MAGENTA,
label = "Headwind", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false
color = if (isNightMode(context)) Color.WHITE else Color.BLACK,
label = "Head", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false,
colorFunc = { headwindSpeed ->
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
interpolateWindColor(headwindSpeedInKmh, isNightMode(context), context).toArgb()
},
alpha = 255
))
}
}
}
companion object {
const val HEADWIND_SAMPLE_COUNT = 30
const val HEADWIND_SAMPLE_COUNT = 50
}
}

View File

@ -9,30 +9,33 @@ import android.graphics.Paint
import android.graphics.Paint.Align
import android.graphics.Path
import androidx.annotation.ColorInt
import kotlin.math.abs
import androidx.core.graphics.createBitmap
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToInt
fun isNightMode(context: Context): Boolean {
val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES
}
class LineGraphBuilder(val context: Context) {
enum class YAxis {
LEFT, RIGHT
}
data class DataPoint(val x: Float, val y: Float)
data class DataPoint(val x: Float, val y: Float) // color field removed
data class Line(
val dataPoints: List<DataPoint>,
val dataPoints: List<DataPoint>, // DataPoint type is now the new one
@ColorInt val color: Int,
val label: String? = null,
val yAxis: YAxis = YAxis.LEFT, // Default to left Y-axis
val drawCircles: Boolean = true // Default to true
val drawCircles: Boolean = true, // Default to true
val colorFunc: ((Float) -> Int)? = null, // Optional color function for dynamic colors,
val alpha: Int = 80
)
private fun isNightMode(): Boolean {
val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES
}
fun drawLineGraph(
width: Int,
height: Int,
@ -41,10 +44,9 @@ class LineGraphBuilder(val context: Context) {
lines: Set<Line>,
labelProvider: ((Float) -> String)
): Bitmap {
val isNightMode = isNightMode()
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val isNightMode = isNightMode(context)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
@ -287,7 +289,7 @@ class LineGraphBuilder(val context: Context) {
if (!hasRightYAxisData || abs(effectiveMaxYRight - effectiveMinYRight) < 0.0001f) 1f else (effectiveMaxYRight - effectiveMinYRight)
fun mapX(originalX: Float): Float {
return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth
return floor(graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth)
}
fun mapYLeft(originalY: Float): Float {
@ -347,35 +349,113 @@ class LineGraphBuilder(val context: Context) {
for (line in lines) {
if (line.dataPoints.isEmpty()) continue
linePaint.color = line.color
val path = Path()
val firstPoint = line.dataPoints.first()
val mapY = if (line.yAxis == YAxis.LEFT) ::mapYLeft else ::mapYRight
path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y))
if (line.drawCircles) {
canvas.drawCircle(
mapX(firstPoint.x),
mapY(firstPoint.y),
8f,
linePaint.apply { style = Paint.Style.FILL })
}
linePaint.style = Paint.Style.STROKE
// Draw area between line and X axis, colorized per segment (match line colorization)
val zeroY = mapY((if (line.yAxis == YAxis.LEFT) effectiveMinYLeft else effectiveMinYRight).coerceAtLeast(0f))
for (i in 1 until line.dataPoints.size) {
val point = line.dataPoints[i]
path.lineTo(mapX(point.x), mapY(point.y))
if (line.drawCircles) {
canvas.drawCircle(
mapX(point.x),
mapY(point.y),
8f,
linePaint.apply { style = Paint.Style.FILL })
val prev = line.dataPoints[i - 1]
val curr = line.dataPoints[i]
if (line.colorFunc != null) {
val N = 4 // Number of sub-segments (tweak for smoothness/performance)
val adjustment = 0.5f // Pixel adjustment to help close seams
for (j in 0 until N) {
val t0 = j / N.toFloat()
val t1 = (j + 1) / N.toFloat()
val x0 = prev.x + (curr.x - prev.x) * t0
val y0 = prev.y + (curr.y - prev.y) * t0
val x1 = prev.x + (curr.x - prev.x) * t1
val y1 = prev.y + (curr.y - prev.y) * t1
val color0 = line.colorFunc.invoke(y0)
val mappedX0 = mapX(x0)
val mappedY0 = mapY(y0)
val mappedX1 = mapX(x1)
val mappedY1 = mapY(y1)
// Extend the right edge of internal sub-segments to create an overlap
val rightEdgeX = if (j < N - 1) mappedX1 + adjustment else mappedX1
val areaPath = Path().apply {
moveTo(mappedX0, mappedY0)
lineTo(rightEdgeX, mappedY1)
lineTo(rightEdgeX, zeroY)
lineTo(mappedX0, zeroY)
close()
}
val areaPaint = Paint().apply {
style = Paint.Style.FILL
color = color0
isAntiAlias = true
alpha = line.alpha
}
canvas.drawPath(areaPath, areaPaint)
}
} else {
val areaPath = Path().apply {
moveTo(mapX(prev.x), mapY(prev.y))
lineTo(mapX(curr.x), mapY(curr.y))
lineTo(mapX(curr.x), zeroY)
lineTo(mapX(prev.x), zeroY)
close()
}
val areaPaint = Paint().apply {
style = Paint.Style.FILL
color = line.color
isAntiAlias = true
alpha = line.alpha
}
canvas.drawPath(areaPath, areaPaint)
}
linePaint.style = Paint.Style.STROKE
}
canvas.drawPath(path, linePaint)
// Draw the line, colorized per segment (improved: split for colorFunc)
for (i in 1 until line.dataPoints.size) {
val prev = line.dataPoints[i - 1]
val curr = line.dataPoints[i]
if (line.colorFunc != null) {
val N = 4 // Number of sub-segments (tweak for smoothness/performance)
for (j in 0 until N) {
val t0 = j / N.toFloat()
val t1 = (j + 1) / N.toFloat()
val x0 = prev.x + (curr.x - prev.x) * t0
val y0 = prev.y + (curr.y - prev.y) * t0
val x1 = prev.x + (curr.x - prev.x) * t1
val y1 = prev.y + (curr.y - prev.y) * t1
val color0 = line.colorFunc.invoke(y0)
// Optionally, blend color0 and color1 for the segment, or just use color0
val segPaint = Paint(linePaint).apply {
color = color0
}
canvas.drawLine(
mapX(x0), mapY(y0),
mapX(x1), mapY(y1),
segPaint
)
}
} else {
val segPaint = Paint(linePaint).apply {
color = line.color
}
canvas.drawLine(
mapX(prev.x), mapY(prev.y),
mapX(curr.x), mapY(curr.y),
segPaint
)
}
}
// Draw circles if enabled
if (line.drawCircles) {
for (point in line.dataPoints) {
val circlePaint = Paint(linePaint).apply {
style = Paint.Style.FILL
color = line.colorFunc?.invoke(point.y) ?: line.color
}
canvas.drawCircle(mapX(point.x), mapY(point.y), 8f, circlePaint)
}
}
}
// Draw Left Y-axis ticks and labels