Compare commits
4 Commits
1b450c00d9
...
b4e5f42007
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e5f42007 | |||
|
|
d56a220ae1 | ||
|
|
89cb2ec010 | ||
|
|
839b93a43b |
@ -29,6 +29,7 @@ After installing this app on your Karoo and opening it once from the main menu,
|
|||||||
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default. If placed in a 1x1 datafield, only the current weather conditions are shown.
|
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default. If placed in a 1x1 datafield, only the current weather conditions are shown.
|
||||||
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind).
|
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind).
|
||||||
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
|
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
|
||||||
|
- Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr.
|
||||||
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
|
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
|
||||||
|
|
||||||
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
|
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
|
||||||
|
|||||||
@ -72,7 +72,7 @@ tasks.register("generateManifest") {
|
|||||||
"latestVersionCode" to android.defaultConfig.versionCode,
|
"latestVersionCode" to android.defaultConfig.versionCode,
|
||||||
"developer" to "github.com/timklge",
|
"developer" to "github.com/timklge",
|
||||||
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
|
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
|
||||||
"releaseNotes" to "* Fix unit of plain wind gust / speed datafield\n* Fix headwind forecast preview\n* Add UV-index datafield (thx @saversux!)\n* Readd a datafield that shows headwind direction and absolute wind speed datafield",
|
"releaseNotes" to "* Open main extension menu when clicking on graphical headwind / tailwind datafield\n* Only update position if the estimated accuracy is within 500 meters\n* Add force distribution datafield\n* Only increase relative elevation gain when relative grade is positive",
|
||||||
"screenshotUrls" to listOf(
|
"screenshotUrls" to listOf(
|
||||||
"$baseUrl/preview1.png",
|
"$baseUrl/preview1.png",
|
||||||
"$baseUrl/preview3.png",
|
"$baseUrl/preview3.png",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType
|
|||||||
import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType
|
import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType
|
||||||
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
|
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
|
||||||
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
|
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
|
||||||
|
import de.timklge.karooheadwind.datatypes.ResistanceForcesDataType
|
||||||
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
|
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
|
||||||
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
|
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
|
||||||
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
|
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
|
||||||
@ -87,7 +88,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
|
|||||||
RelativeGradeDataType(karooSystem, applicationContext),
|
RelativeGradeDataType(karooSystem, applicationContext),
|
||||||
RelativeElevationGainDataType(karooSystem, applicationContext),
|
RelativeElevationGainDataType(karooSystem, applicationContext),
|
||||||
TemperatureDataType(karooSystem, applicationContext),
|
TemperatureDataType(karooSystem, applicationContext),
|
||||||
UviDataType(karooSystem, applicationContext)
|
UviDataType(karooSystem, applicationContext),
|
||||||
|
ResistanceForcesDataType(karooSystem, applicationContext)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ fun HeadwindDirection(
|
|||||||
val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp)
|
val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = baseModifier, // TODO if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier,
|
modifier = if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier,
|
||||||
contentAlignment = Alignment(
|
contentAlignment = Alignment(
|
||||||
vertical = Alignment.Vertical.CenterVertically,
|
vertical = Alignment.Vertical.CenterVertically,
|
||||||
horizontal = Alignment.Horizontal.CenterHorizontally,
|
horizontal = Alignment.Horizontal.CenterHorizontally,
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class RelativeElevationGainDataType(private val karooSystemService: KarooSystemS
|
|||||||
val gradeDifferenceDueToWind = relativeGrade - actualGrade
|
val gradeDifferenceDueToWind = relativeGrade - actualGrade
|
||||||
var intervalWindElevation = 0.0
|
var intervalWindElevation = 0.0
|
||||||
|
|
||||||
if (gradeDifferenceDueToWind > 0) {
|
if (gradeDifferenceDueToWind > 0 && relativeGrade > 0) {
|
||||||
val distanceCovered = riderSpeed * deltaTime
|
val distanceCovered = riderSpeed * deltaTime
|
||||||
intervalWindElevation = distanceCovered * gradeDifferenceDueToWind
|
intervalWindElevation = distanceCovered * gradeDifferenceDueToWind
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,9 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.timklge.karooheadwind.HeadingResponse
|
import de.timklge.karooheadwind.HeadingResponse
|
||||||
import de.timklge.karooheadwind.KarooHeadwindExtension
|
import de.timklge.karooheadwind.KarooHeadwindExtension
|
||||||
import de.timklge.karooheadwind.WeatherDataProvider
|
|
||||||
import de.timklge.karooheadwind.getRelativeHeadingFlow
|
import de.timklge.karooheadwind.getRelativeHeadingFlow
|
||||||
import de.timklge.karooheadwind.streamCurrentWeatherData
|
import de.timklge.karooheadwind.streamCurrentWeatherData
|
||||||
import de.timklge.karooheadwind.streamDataFlow
|
import de.timklge.karooheadwind.streamDataFlow
|
||||||
import de.timklge.karooheadwind.streamSettings
|
|
||||||
import de.timklge.karooheadwind.streamUserProfile
|
import de.timklge.karooheadwind.streamUserProfile
|
||||||
import de.timklge.karooheadwind.throttle
|
import de.timklge.karooheadwind.throttle
|
||||||
import io.hammerhead.karooext.KarooSystemService
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
@ -19,7 +17,6 @@ import io.hammerhead.karooext.models.DataPoint
|
|||||||
import io.hammerhead.karooext.models.DataType
|
import io.hammerhead.karooext.models.DataType
|
||||||
import io.hammerhead.karooext.models.StreamState
|
import io.hammerhead.karooext.models.StreamState
|
||||||
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
||||||
import io.hammerhead.karooext.models.UserProfile
|
|
||||||
import io.hammerhead.karooext.models.ViewConfig
|
import io.hammerhead.karooext.models.ViewConfig
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -36,12 +33,88 @@ import kotlin.math.cos
|
|||||||
class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") {
|
class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") {
|
||||||
data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double)
|
data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double)
|
||||||
|
|
||||||
|
data class ResistanceForces(
|
||||||
|
val airResistanceWithoutWind: Double,
|
||||||
|
val airResistanceWithWind: Double,
|
||||||
|
val rollingResistance: Double,
|
||||||
|
val gravitationalForce: Double
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Default physical constants - adjust as needed
|
// Default physical constants - adjust as needed
|
||||||
const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2)
|
const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2)
|
||||||
const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3)
|
const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3)
|
||||||
const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment.
|
const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment.
|
||||||
const val DEFAULT_BIKE_WEIGHT = 10.0 // Default bike weight (kg).
|
const val DEFAULT_BIKE_WEIGHT = 9.0 // Default bike weight (kg).
|
||||||
|
const val DEFAULT_CRR = 0.005 // Default coefficient of rolling resistance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the various resistance forces acting on a cyclist.
|
||||||
|
*
|
||||||
|
* @param actualGrade The current gradient of the road (unitless, e.g., 0.05 for 5%).
|
||||||
|
* @param riderSpeed The speed of the rider relative to the ground (m/s). Must be non-negative.
|
||||||
|
* @param windSpeed The speed of the wind relative to the ground (m/s). Must be non-negative.
|
||||||
|
* @param windDirectionDegrees The direction of the wind relative to the rider's direction
|
||||||
|
* of travel (degrees).
|
||||||
|
* 0 = direct headwind, 90 = crosswind right,
|
||||||
|
* 180 = direct tailwind, 270 = crosswind left.
|
||||||
|
* @param totalMass The combined mass of the rider and the bike (kg). Must be positive.
|
||||||
|
* @param cda The rider's coefficient of drag multiplied by their frontal area (m^2).
|
||||||
|
* Defaults to DEFAULT_CDA. Represents aerodynamic efficiency.
|
||||||
|
* @param crr The coefficient of rolling resistance. Defaults to DEFAULT_CRR.
|
||||||
|
* @param airDensity The density of the air (kg/m^3). Defaults to DEFAULT_AIR_DENSITY.
|
||||||
|
* @param g The acceleration due to gravity (m/s^2). Defaults to DEFAULT_GRAVITY.
|
||||||
|
* @return A [ResistanceForces] object containing the calculated forces, or null
|
||||||
|
* if input parameters are invalid.
|
||||||
|
*/
|
||||||
|
fun estimateResistanceForces(
|
||||||
|
actualGrade: Double,
|
||||||
|
riderSpeed: Double,
|
||||||
|
windSpeed: Double,
|
||||||
|
windDirectionDegrees: Double,
|
||||||
|
totalMass: Double,
|
||||||
|
cda: Double = DEFAULT_CDA,
|
||||||
|
crr: Double = DEFAULT_CRR,
|
||||||
|
airDensity: Double = DEFAULT_AIR_DENSITY,
|
||||||
|
g: Double = DEFAULT_GRAVITY
|
||||||
|
): ResistanceForces? {
|
||||||
|
// --- Input Validation ---
|
||||||
|
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0 || crr < 0.0) {
|
||||||
|
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters for force calculation.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Calculate wind component parallel to rider's direction
|
||||||
|
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
|
||||||
|
|
||||||
|
// 2. Calculate effective air speed
|
||||||
|
val effectiveAirSpeed = riderSpeed + windComponentParallel
|
||||||
|
|
||||||
|
// 3. Calculate aerodynamic resistance factor
|
||||||
|
val aeroFactor = 0.5 * airDensity * cda
|
||||||
|
|
||||||
|
// 4. Calculate air resistance forces
|
||||||
|
// Drag Force = aeroFactor * speed^2 * sign(speed)
|
||||||
|
val airResistanceWithWind = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
|
||||||
|
val airResistanceWithoutWind = aeroFactor * riderSpeed * abs(riderSpeed)
|
||||||
|
|
||||||
|
// 5. Calculate gravitational force (force due to slope)
|
||||||
|
// Decomposing the gravitational force along the slope
|
||||||
|
val gravitationalForce = totalMass * g * actualGrade
|
||||||
|
|
||||||
|
// 6. Calculate rolling resistance force
|
||||||
|
// This is simplified; in reality, it's perpendicular to the road surface.
|
||||||
|
// F_rolling = Crr * N = Crr * m * g * cos(arctan(grade))
|
||||||
|
// For small angles, cos(arctan(grade)) is close to 1, so we approximate.
|
||||||
|
val rollingResistance = totalMass * g * crr
|
||||||
|
|
||||||
|
return ResistanceForces(
|
||||||
|
airResistanceWithoutWind = airResistanceWithoutWind,
|
||||||
|
airResistanceWithWind = airResistanceWithWind,
|
||||||
|
rollingResistance = rollingResistance,
|
||||||
|
gravitationalForce = gravitationalForce
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimates the "relative grade" experienced by a cyclist.
|
* Estimates the "relative grade" experienced by a cyclist.
|
||||||
@ -76,42 +149,42 @@ class RelativeGradeDataType(private val karooSystemService: KarooSystemService,
|
|||||||
airDensity: Double = DEFAULT_AIR_DENSITY,
|
airDensity: Double = DEFAULT_AIR_DENSITY,
|
||||||
g: Double = DEFAULT_GRAVITY,
|
g: Double = DEFAULT_GRAVITY,
|
||||||
): Double {
|
): Double {
|
||||||
// --- Input Validation ---
|
val forces = estimateResistanceForces(
|
||||||
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0) {
|
actualGrade,
|
||||||
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters. Mass/g must be positive; speeds, airDensity, Cda must be non-negative.")
|
riderSpeed,
|
||||||
|
windSpeed,
|
||||||
|
windDirectionDegrees,
|
||||||
|
totalMass,
|
||||||
|
cda,
|
||||||
|
DEFAULT_CRR,
|
||||||
|
airDensity,
|
||||||
|
g
|
||||||
|
)
|
||||||
|
|
||||||
|
if (forces == null) {
|
||||||
|
Log.w(KarooHeadwindExtension.TAG, "Could not calculate forces for relative grade.")
|
||||||
return Double.NaN
|
return Double.NaN
|
||||||
}
|
}
|
||||||
|
|
||||||
if (riderSpeed == 0.0 && windSpeed == 0.0) {
|
if (riderSpeed == 0.0 && windSpeed == 0.0) {
|
||||||
// If no movement and no wind, relative grade is just the actual grade
|
// If no movement and no wind, relative grade is just the actual grade
|
||||||
return actualGrade
|
return actualGrade
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Calculate the component of wind speed parallel to the rider's direction of travel.
|
// The difference in force is purely from the wind.
|
||||||
// cos(0 rad) = 1 (headwind), cos(PI rad) = -1 (tailwind)
|
// This difference in force, when equated to a change in gravitational force, gives the change in grade.
|
||||||
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
|
// delta_F_air = F_air_with_wind - F_air_without_wind
|
||||||
|
// delta_F_air = m * g * delta_grade
|
||||||
// 2. Calculate the effective air speed the rider experiences.
|
// delta_grade = delta_F_air / (m * g)
|
||||||
// This is rider speed + the parallel wind component.
|
// relative_grade = actual_grade + delta_grade
|
||||||
val effectiveAirSpeed = riderSpeed + windComponentParallel
|
val dragForceDifference = forces.airResistanceWithWind - forces.airResistanceWithoutWind
|
||||||
|
|
||||||
// 3. Calculate the aerodynamic resistance factor constant part.
|
|
||||||
val aeroFactor = 0.5 * airDensity * cda
|
|
||||||
|
|
||||||
// 4. Calculate the gravitational force component denominator.
|
|
||||||
val gravitationalFactor = totalMass * g
|
val gravitationalFactor = totalMass * g
|
||||||
|
|
||||||
// 5. Calculate the difference in the aerodynamic drag force term between
|
if (gravitationalFactor == 0.0) {
|
||||||
// the current situation (with wind) and the hypothetical no-wind situation.
|
return actualGrade // Avoid division by zero
|
||||||
// Drag Force = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
|
}
|
||||||
// We use speed * abs(speed) to ensure drag always opposes relative air motion.
|
|
||||||
val dragForceDifference = aeroFactor * ( (effectiveAirSpeed * abs(effectiveAirSpeed)) - (riderSpeed * abs(riderSpeed)) )
|
|
||||||
|
|
||||||
// 6. Calculate the relative grade.
|
return actualGrade + (dragForceDifference / gravitationalFactor)
|
||||||
// It's the actual grade plus the equivalent grade change caused by the wind.
|
|
||||||
// Equivalent Grade Change = Drag Force Difference / Gravitational Force Component
|
|
||||||
val relativeGrade = actualGrade + (dragForceDifference / gravitationalFactor)
|
|
||||||
|
|
||||||
return relativeGrade
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> {
|
suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> {
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
package de.timklge.karooheadwind.datatypes
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
|
||||||
|
import androidx.glance.appwidget.GlanceRemoteViews
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.Image
|
||||||
|
import de.timklge.karooheadwind.HeadingResponse
|
||||||
|
import de.timklge.karooheadwind.KarooHeadwindExtension
|
||||||
|
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType.Companion.DEFAULT_BIKE_WEIGHT
|
||||||
|
import de.timklge.karooheadwind.getRelativeHeadingFlow
|
||||||
|
import de.timklge.karooheadwind.screens.BarChartBuilder
|
||||||
|
import de.timklge.karooheadwind.streamCurrentWeatherData
|
||||||
|
import de.timklge.karooheadwind.streamDataFlow
|
||||||
|
import de.timklge.karooheadwind.streamUserProfile
|
||||||
|
import de.timklge.karooheadwind.throttle
|
||||||
|
import de.timklge.karooheadwind.weatherprovider.WeatherData
|
||||||
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
|
import io.hammerhead.karooext.internal.ViewEmitter
|
||||||
|
import io.hammerhead.karooext.models.DataType
|
||||||
|
import io.hammerhead.karooext.models.ShowCustomStreamState
|
||||||
|
import io.hammerhead.karooext.models.StreamState
|
||||||
|
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
||||||
|
import io.hammerhead.karooext.models.UserProfile
|
||||||
|
import io.hammerhead.karooext.models.ViewConfig
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ResistanceForcesDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(karooSystem, context, "forces"){
|
||||||
|
|
||||||
|
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
|
||||||
|
return data.windDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previewFlow(): Flow<RelativeGradeDataType.ResistanceForces> {
|
||||||
|
return flow {
|
||||||
|
while (true) {
|
||||||
|
val withoutWind = (0..300).random().toDouble()
|
||||||
|
emit(RelativeGradeDataType.ResistanceForces(
|
||||||
|
withoutWind,
|
||||||
|
withoutWind + (0..200).random().toDouble(),
|
||||||
|
(0..50).random().toDouble(),
|
||||||
|
(-100..500).random().toDouble()
|
||||||
|
))
|
||||||
|
delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||||
|
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
|
||||||
|
val configJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
emitter.onNext(UpdateGraphicConfig(showHeader = false))
|
||||||
|
awaitCancellation()
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
emitter.onNext(ShowCustomStreamState("", null))
|
||||||
|
|
||||||
|
val flow = if (config.preview) {
|
||||||
|
previewFlow()
|
||||||
|
} else {
|
||||||
|
val relativeWindDirectionFlow = karooSystem.getRelativeHeadingFlow(context).filterIsInstance<HeadingResponse.Value>().map { it.diff + 180 }
|
||||||
|
val speedFlow = karooSystem.streamDataFlow(DataType.Type.SPEED).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue ?: 0.0 }
|
||||||
|
val actualGradeFlow = karooSystem.streamDataFlow(DataType.Type.ELEVATION_GRADE).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue }.filterNotNull().map { it / 100.0 } // Convert to decimal grade
|
||||||
|
val totalMassFlow = karooSystem.streamUserProfile().map {
|
||||||
|
if (it.weight in 30.0f..300.0f){
|
||||||
|
it.weight
|
||||||
|
} else {
|
||||||
|
Log.w(KarooHeadwindExtension.TAG, "Invalid rider weight ${it.weight} kg, defaulting to 70 kg")
|
||||||
|
70.0f // Default to 70 kg if weight is invalid
|
||||||
|
} + DEFAULT_BIKE_WEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
|
||||||
|
|
||||||
|
val windSpeedFlow = context.streamCurrentWeatherData(karooSystem).filterNotNull().map { weatherData ->
|
||||||
|
weatherData.windSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamValues(
|
||||||
|
val relativeWindDirection: Double,
|
||||||
|
val speed: Double,
|
||||||
|
val windSpeed: Double,
|
||||||
|
val actualGrade: Double,
|
||||||
|
val totalMass: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
combine(relativeWindDirectionFlow, speedFlow, windSpeedFlow, actualGradeFlow, totalMassFlow) { windDirection, speed, windSpeed, actualGrade, totalMass ->
|
||||||
|
StreamValues(windDirection, speed, windSpeed, actualGrade, totalMass)
|
||||||
|
}.distinctUntilChanged().throttle(refreshRate).map { (windDirection, speed, windSpeed, actualGrade, totalMass) ->
|
||||||
|
val resistanceForces = RelativeGradeDataType.estimateResistanceForces(
|
||||||
|
actualGrade = actualGrade,
|
||||||
|
riderSpeed = speed,
|
||||||
|
windSpeed = windSpeed,
|
||||||
|
windDirectionDegrees = windDirection,
|
||||||
|
totalMass = totalMass
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(KarooHeadwindExtension.TAG, "Resistance Forces: $resistanceForces")
|
||||||
|
|
||||||
|
resistanceForces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
|
||||||
|
|
||||||
|
flow.throttle(refreshRate).collect { resistanceForces ->
|
||||||
|
if (resistanceForces != null) {
|
||||||
|
// Create bar chart data
|
||||||
|
val bars = listOf(
|
||||||
|
BarChartBuilder.BarData(
|
||||||
|
value = resistanceForces.airResistanceWithoutWind,
|
||||||
|
label = "Air",
|
||||||
|
smallLabel = "Air",
|
||||||
|
color = 0xFF4CAF50.toInt() // Green
|
||||||
|
),
|
||||||
|
BarChartBuilder.BarData(
|
||||||
|
value = resistanceForces.airResistanceWithWind - resistanceForces.airResistanceWithoutWind,
|
||||||
|
label = "Wind",
|
||||||
|
smallLabel = "Wind",
|
||||||
|
color = 0xFF2196F3.toInt() // Blue
|
||||||
|
),
|
||||||
|
BarChartBuilder.BarData(
|
||||||
|
value = resistanceForces.rollingResistance,
|
||||||
|
label = "Roll",
|
||||||
|
smallLabel = "R",
|
||||||
|
color = 0xFFFF9800.toInt() // Orange
|
||||||
|
),
|
||||||
|
BarChartBuilder.BarData(
|
||||||
|
value = resistanceForces.gravitationalForce,
|
||||||
|
label = "Gravity",
|
||||||
|
smallLabel = "G",
|
||||||
|
color = 0xFFF44336.toInt() // Red
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw bar chart
|
||||||
|
val bitmap = BarChartBuilder(context).drawBarChart(
|
||||||
|
width = config.viewSize.first,
|
||||||
|
height = config.viewSize.second,
|
||||||
|
bars = bars,
|
||||||
|
small = config.gridSize.first <= 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use the correct ViewEmitter pattern with glance.compose
|
||||||
|
val glance = GlanceRemoteViews()
|
||||||
|
val result = glance.compose(context, DpSize.Unspecified) {
|
||||||
|
Box(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
Image(
|
||||||
|
ImageProvider(bitmap),
|
||||||
|
"Resistance Forces Bar Chart",
|
||||||
|
modifier = GlanceModifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitter.updateView(result.remoteViews)
|
||||||
|
} else {
|
||||||
|
// Display error message when no resistance forces data
|
||||||
|
emitter.onNext(ShowCustomStreamState("No resistance data", null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.setCancellable {
|
||||||
|
configJob.cancel()
|
||||||
|
viewJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/src/main/kotlin/de/timklge/karooheadwind/screens/BarChart.kt
Normal file
153
app/src/main/kotlin/de/timklge/karooheadwind/screens/BarChart.kt
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package de.timklge.karooheadwind.screens
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class BarChartBuilder(val context: Context) {
|
||||||
|
|
||||||
|
data class BarData(
|
||||||
|
val value: Double,
|
||||||
|
val label: String,
|
||||||
|
val smallLabel: String,
|
||||||
|
@ColorInt val color: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun drawBarChart(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
small: Boolean,
|
||||||
|
bars: List<BarData>
|
||||||
|
): Bitmap {
|
||||||
|
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
|
||||||
|
|
||||||
|
canvas.drawColor(backgroundColor)
|
||||||
|
|
||||||
|
if (bars.isEmpty()) {
|
||||||
|
val emptyPaint = Paint().apply {
|
||||||
|
color = primaryTextColor
|
||||||
|
textSize = 30f
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
val marginTop = 45f
|
||||||
|
val marginBottom = 45f
|
||||||
|
val marginLeft = 5f
|
||||||
|
val marginRight = 5f
|
||||||
|
|
||||||
|
val chartWidth = width - marginLeft - marginRight
|
||||||
|
val chartHeight = height - marginTop - marginBottom
|
||||||
|
val chartLeft = marginLeft
|
||||||
|
val chartTop = marginTop
|
||||||
|
val chartBottom = height - marginBottom
|
||||||
|
|
||||||
|
// Find the maximum absolute value to determine scale
|
||||||
|
val maxValue = bars.maxOfOrNull { abs(it.value) } ?: 1.0
|
||||||
|
val minValue = bars.minOfOrNull { it.value } ?: 0.0
|
||||||
|
|
||||||
|
// Determine if we need to show negative values
|
||||||
|
val hasNegativeValues = minValue < 0
|
||||||
|
val zeroY = if (hasNegativeValues) {
|
||||||
|
chartTop + chartHeight * (maxValue / (maxValue - minValue)).toFloat()
|
||||||
|
} else {
|
||||||
|
chartBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bar dimensions
|
||||||
|
val barSpacing = 10f
|
||||||
|
val totalSpacing = (bars.size - 1) * barSpacing
|
||||||
|
val barWidth = (chartWidth - totalSpacing) / bars.size
|
||||||
|
|
||||||
|
// Draw bars
|
||||||
|
val barPaint = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
|
||||||
|
bars.forEachIndexed { index, bar ->
|
||||||
|
val barLeft = chartLeft + index * (barWidth + barSpacing)
|
||||||
|
val barRight = barLeft + barWidth
|
||||||
|
|
||||||
|
val barHeight = if (hasNegativeValues) {
|
||||||
|
(abs(bar.value) / (maxValue - minValue) * chartHeight).toFloat()
|
||||||
|
} else {
|
||||||
|
(bar.value / maxValue * chartHeight).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
val barTop = if (bar.value >= 0) {
|
||||||
|
zeroY - barHeight
|
||||||
|
} else {
|
||||||
|
zeroY
|
||||||
|
}
|
||||||
|
|
||||||
|
val barBottom = if (bar.value >= 0) {
|
||||||
|
zeroY
|
||||||
|
} else {
|
||||||
|
zeroY + barHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bar
|
||||||
|
barPaint.color = bar.color
|
||||||
|
val rect = RectF(barLeft, barTop, barRight, barBottom)
|
||||||
|
canvas.drawRect(rect, barPaint)
|
||||||
|
|
||||||
|
// Draw label where value used to be with increased font size
|
||||||
|
val labelPaint = Paint().apply {
|
||||||
|
color = primaryTextColor
|
||||||
|
textSize = 32f // Increased from 24f and 28f
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use smallLabel if small is true, otherwise use regular label
|
||||||
|
val labelToUse = if (small) bar.smallLabel else bar.label
|
||||||
|
|
||||||
|
val labelY = if (bar.value >= 0) {
|
||||||
|
barTop - 10f // Position above positive bars
|
||||||
|
} else {
|
||||||
|
barBottom + labelPaint.textSize + 10f // Position below negative bars
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create semi-transparent background box for label
|
||||||
|
val backgroundPaint = Paint().apply {
|
||||||
|
color = if (isNightMode) Color.argb(200, 0, 0, 0) else Color.argb(200, 255, 255, 255)
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate text bounds for background box
|
||||||
|
val textBounds = android.graphics.Rect()
|
||||||
|
labelPaint.getTextBounds(labelToUse, 0, labelToUse.length, textBounds)
|
||||||
|
|
||||||
|
val padding = 8f
|
||||||
|
val boxLeft = barLeft + barWidth / 2f - textBounds.width() / 2f - padding
|
||||||
|
val boxRight = barLeft + barWidth / 2f + textBounds.width() / 2f + padding
|
||||||
|
val boxTop = labelY - textBounds.height() - padding / 2f
|
||||||
|
val boxBottom = labelY + padding / 2f
|
||||||
|
|
||||||
|
// Draw rounded rectangle background
|
||||||
|
val backgroundRect = RectF(boxLeft, boxTop, boxRight, boxBottom)
|
||||||
|
val cornerRadius = 6f
|
||||||
|
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint)
|
||||||
|
|
||||||
|
canvas.drawText(labelToUse, barLeft + barWidth / 2f, labelY, labelPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,4 +47,6 @@
|
|||||||
<string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string>
|
<string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string>
|
||||||
<string name="windDirectionAndSpeedCircle">Wind direction, speed (Circle)</string>
|
<string name="windDirectionAndSpeedCircle">Wind direction, speed (Circle)</string>
|
||||||
<string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string>
|
<string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string>
|
||||||
|
<string name="forces">Resistance forces</string>
|
||||||
|
<string name="forces_description">Current resistance forces (air, rolling, gradient)</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -158,4 +158,11 @@
|
|||||||
graphical="false"
|
graphical="false"
|
||||||
icon="@drawable/wind"
|
icon="@drawable/wind"
|
||||||
typeId="relativeElevationGain" />
|
typeId="relativeElevationGain" />
|
||||||
|
|
||||||
|
<DataType
|
||||||
|
description="@string/forces_description"
|
||||||
|
displayName="@string/forces"
|
||||||
|
graphical="true"
|
||||||
|
icon="@drawable/wind"
|
||||||
|
typeId="forces" />
|
||||||
</ExtensionInfo>
|
</ExtensionInfo>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user