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

View File

@ -1,5 +1,6 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.UpcomingRoute import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.screens.LineGraphBuilder import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -9,7 +10,8 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line> { ): Set<LineGraphBuilder.Line> {
val precipitationPoints = lineData.map { data -> val precipitationPoints = lineData.map { data ->
if (isImperial) { // Convert mm to inches 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.KarooHeadwindExtension
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamDatatypeIsVisible import de.timklge.karooheadwind.streamDatatypeIsVisible
@ -199,14 +200,10 @@ class TailwindAndRideSpeedDataType(
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue} ${windSpeedUserUnit.roundToInt()}${gustSpeedAddon}" "$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 headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6 val windSpeedInKmh = headwindSpeed * 3.6
dayColor = interpolateWindColor(windSpeedInKmh, false, context) val dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context) val nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val result = glance.compose(context, DpSize.Unspecified) { val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection( HeadwindDirection(

View File

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

View File

@ -1,12 +1,16 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Color
import android.util.Log import android.util.Log
import androidx.compose.ui.graphics.toArgb
import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.UpcomingRoute import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.lerpWeather import de.timklge.karooheadwind.lerpWeather
import de.timklge.karooheadwind.screens.LineGraphBuilder import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.util.signedAngleDifference import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.ceil import kotlin.math.ceil
@ -23,7 +27,8 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean isPreview: Boolean,
context: Context
): Set<LineGraphBuilder.Line> { ): Set<LineGraphBuilder.Line> {
val windPoints = lineData.map { data -> val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph if (isImperial) { // Convert m/s to mph
@ -47,16 +52,21 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
val t = i / HEADWIND_SAMPLE_COUNT.toDouble() val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) { 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 if (upcomingRoute == null) return@mapNotNull null
val beforeLineData = lineData.getOrNull(floor(lineData.size * t).toInt()) ?: lineData.firstOrNull() val beforeLineData = lineData.getOrNull(floor((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil(lineData.size * t).toInt()) ?: lineData.lastOrNull() val afterLineData = lineData.getOrNull(ceil((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null 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(), val dt = remap(t.toFloat(),
floor(lineData.size * t).toFloat() / lineData.size, 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 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 { } else {
emptyList() emptyList()
@ -106,34 +119,40 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
} }
return buildSet { 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( add(LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value -> dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat()) LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
}, },
color = android.graphics.Color.DKGRAY, color = Color.DKGRAY,
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph", 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()) { if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line( add(LineGraphBuilder.Line(
dataPoints = headwindPoints, dataPoints = headwindPoints,
color = android.graphics.Color.MAGENTA, color = if (isNightMode(context)) Color.WHITE else Color.BLACK,
label = "Headwind", // if (!isImperial) "Headwind km/h" else "Headwind mph", label = "Head", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false 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 { 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.Paint.Align
import android.graphics.Path import android.graphics.Path
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import kotlin.math.abs
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToInt 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) { class LineGraphBuilder(val context: Context) {
enum class YAxis { enum class YAxis {
LEFT, RIGHT 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( data class Line(
val dataPoints: List<DataPoint>, val dataPoints: List<DataPoint>, // DataPoint type is now the new one
@ColorInt val color: Int, @ColorInt val color: Int,
val label: String? = null, val label: String? = null,
val yAxis: YAxis = YAxis.LEFT, // Default to left Y-axis 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( fun drawLineGraph(
width: Int, width: Int,
height: Int, height: Int,
@ -41,10 +44,9 @@ class LineGraphBuilder(val context: Context) {
lines: Set<Line>, lines: Set<Line>,
labelProvider: ((Float) -> String) labelProvider: ((Float) -> String)
): Bitmap { ): Bitmap {
val isNightMode = isNightMode()
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
val isNightMode = isNightMode(context)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK 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) if (!hasRightYAxisData || abs(effectiveMaxYRight - effectiveMinYRight) < 0.0001f) 1f else (effectiveMaxYRight - effectiveMinYRight)
fun mapX(originalX: Float): Float { fun mapX(originalX: Float): Float {
return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth return floor(graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth)
} }
fun mapYLeft(originalY: Float): Float { fun mapYLeft(originalY: Float): Float {
@ -347,35 +349,113 @@ class LineGraphBuilder(val context: Context) {
for (line in lines) { for (line in lines) {
if (line.dataPoints.isEmpty()) continue 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 val mapY = if (line.yAxis == YAxis.LEFT) ::mapYLeft else ::mapYRight
path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y)) // Draw area between line and X axis, colorized per segment (match line colorization)
if (line.drawCircles) { val zeroY = mapY((if (line.yAxis == YAxis.LEFT) effectiveMinYLeft else effectiveMinYRight).coerceAtLeast(0f))
canvas.drawCircle(
mapX(firstPoint.x),
mapY(firstPoint.y),
8f,
linePaint.apply { style = Paint.Style.FILL })
}
linePaint.style = Paint.Style.STROKE
for (i in 1 until line.dataPoints.size) { for (i in 1 until line.dataPoints.size) {
val point = line.dataPoints[i] val prev = line.dataPoints[i - 1]
path.lineTo(mapX(point.x), mapY(point.y)) val curr = line.dataPoints[i]
if (line.drawCircles) { if (line.colorFunc != null) {
canvas.drawCircle( val N = 4 // Number of sub-segments (tweak for smoothness/performance)
mapX(point.x), val adjustment = 0.5f // Pixel adjustment to help close seams
mapY(point.y),
8f, for (j in 0 until N) {
linePaint.apply { style = Paint.Style.FILL }) 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 // Draw Left Y-axis ticks and labels