Colorize headwind forecast line graph and the area beneath it (#155)
This commit is contained in:
parent
87a244ef27
commit
c2c6f1a90f
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user