fix #98: Show temperature, precipitation, temperature forecasts as line graphs
All checks were successful
Build / build (push) Successful in 6m20s
All checks were successful
Build / build (push) Successful in 6m20s
This commit is contained in:
parent
abf2757d08
commit
cc51a485af
@ -294,41 +294,10 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
|
|||||||
"Distance along route ${positionIndex}: $position"
|
"Distance along route ${positionIndex}: $position"
|
||||||
)
|
)
|
||||||
|
|
||||||
val distanceFromCurrent =
|
val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
|
||||||
upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
|
|
||||||
distanceAlongRoute?.minus(currentDistanceAlongRoute)
|
distanceAlongRoute?.minus(currentDistanceAlongRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isCurrent = baseIndex == 0 && positionIndex == 0
|
|
||||||
|
|
||||||
if (isCurrent && data?.current != null) {
|
|
||||||
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
|
|
||||||
val unixTime = data.current.time
|
|
||||||
val formattedTime =
|
|
||||||
timeFormatter.format(Instant.ofEpochSecond(unixTime))
|
|
||||||
val formattedDate =
|
|
||||||
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
|
|
||||||
val hasNewDate = formattedDate != previousDate || baseIndex == 0
|
|
||||||
|
|
||||||
RenderWidget(
|
|
||||||
arrowBitmap = baseBitmap,
|
|
||||||
current = interpretation,
|
|
||||||
windBearing = data.current.windDirection.roundToInt(),
|
|
||||||
windSpeed = msInUserUnit(data.current.windSpeed, settingsAndProfile.isImperial).roundToInt(),
|
|
||||||
windGusts = msInUserUnit(data.current.windGusts, settingsAndProfile.isImperial).roundToInt(),
|
|
||||||
precipitation = millimetersInUserUnit(data.current.precipitation, settingsAndProfile.isImperial),
|
|
||||||
precipitationProbability = null,
|
|
||||||
temperature = celciusInUserUnit(data.current.temperature, settingsAndProfile.isImperialTemperature).roundToInt(),
|
|
||||||
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
|
|
||||||
timeLabel = formattedTime,
|
|
||||||
dateLabel = if (hasNewDate) formattedDate else null,
|
|
||||||
distance = null,
|
|
||||||
isImperial = settingsAndProfile.isImperial,
|
|
||||||
isNight = data.current.isNight
|
|
||||||
)
|
|
||||||
|
|
||||||
previousDate = formattedDate
|
|
||||||
} else {
|
|
||||||
val weatherData = data?.forecasts?.getOrNull(baseIndex)
|
val weatherData = data?.forecasts?.getOrNull(baseIndex)
|
||||||
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
|
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
|
||||||
val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
|
val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
|
||||||
@ -357,7 +326,6 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
emitter.updateView(result.remoteViews)
|
emitter.updateView(result.remoteViews)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,285 @@
|
|||||||
|
package de.timklge.karooheadwind.datatypes
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.Image
|
||||||
|
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 de.timklge.karooheadwind.HeadingResponse
|
||||||
|
import de.timklge.karooheadwind.HeadwindSettings
|
||||||
|
import de.timklge.karooheadwind.HeadwindWidgetSettings
|
||||||
|
import de.timklge.karooheadwind.KarooHeadwindExtension
|
||||||
|
import de.timklge.karooheadwind.UpcomingRoute
|
||||||
|
import de.timklge.karooheadwind.WeatherDataProvider
|
||||||
|
import de.timklge.karooheadwind.getHeadingFlow
|
||||||
|
import de.timklge.karooheadwind.screens.LineGraphBuilder
|
||||||
|
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
|
||||||
|
import de.timklge.karooheadwind.streamDatatypeIsVisible
|
||||||
|
import de.timklge.karooheadwind.streamSettings
|
||||||
|
import de.timklge.karooheadwind.streamUpcomingRoute
|
||||||
|
import de.timklge.karooheadwind.streamUserProfile
|
||||||
|
import de.timklge.karooheadwind.streamWidgetSettings
|
||||||
|
import de.timklge.karooheadwind.throttle
|
||||||
|
import de.timklge.karooheadwind.weatherprovider.WeatherData
|
||||||
|
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
|
||||||
|
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
|
||||||
|
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
|
||||||
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
|
import io.hammerhead.karooext.extension.DataTypeImpl
|
||||||
|
import io.hammerhead.karooext.internal.ViewEmitter
|
||||||
|
import io.hammerhead.karooext.models.ShowCustomStreamState
|
||||||
|
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.filter
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
|
||||||
|
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||||
|
private val glance = GlanceRemoteViews()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
|
||||||
|
val widgetSettings: HeadwindWidgetSettings? = null,
|
||||||
|
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean)
|
||||||
|
|
||||||
|
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean, val isImperialTemperature: Boolean)
|
||||||
|
|
||||||
|
data class LineData(val time: Instant? = null, val x: Float? = null, val weatherData: WeatherData)
|
||||||
|
|
||||||
|
abstract fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line>
|
||||||
|
|
||||||
|
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
|
||||||
|
flow {
|
||||||
|
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val data = (0..<10).map { index ->
|
||||||
|
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
|
||||||
|
|
||||||
|
val weatherData = (0..<12).map {
|
||||||
|
val forecastTime = timeAtFullHour + it * 60 * 60
|
||||||
|
val forecastTemperature = 20.0 + (-20..20).random()
|
||||||
|
val forecastPrecipitation = 0.0 + (0..10).random()
|
||||||
|
val forecastPrecipitationProbability = (0..100).random()
|
||||||
|
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
|
||||||
|
val forecastWindSpeed = 0.0 + (0..10).random()
|
||||||
|
val forecastWindDirection = 0.0 + (0..360).random()
|
||||||
|
val forecastWindGusts = 0.0 + (0..10).random()
|
||||||
|
WeatherData(
|
||||||
|
time = forecastTime,
|
||||||
|
temperature = forecastTemperature,
|
||||||
|
relativeHumidity = 20,
|
||||||
|
precipitation = forecastPrecipitation,
|
||||||
|
cloudCover = 3.0,
|
||||||
|
sealevelPressure = 1013.25,
|
||||||
|
surfacePressure = 1013.25,
|
||||||
|
precipitationProbability = forecastPrecipitationProbability.toDouble(),
|
||||||
|
windSpeed = forecastWindSpeed,
|
||||||
|
windDirection = forecastWindDirection,
|
||||||
|
windGusts = forecastWindGusts,
|
||||||
|
weatherCode = forecastWeatherCode,
|
||||||
|
isForecast = true,
|
||||||
|
isNight = it < 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val distancePerHour =
|
||||||
|
settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)
|
||||||
|
?.toDouble() ?: 0.0
|
||||||
|
|
||||||
|
WeatherDataForLocation(
|
||||||
|
current = WeatherData(
|
||||||
|
time = timeAtFullHour,
|
||||||
|
temperature = 20.0,
|
||||||
|
relativeHumidity = 20,
|
||||||
|
precipitation = 0.0,
|
||||||
|
cloudCover = 3.0,
|
||||||
|
sealevelPressure = 1013.25,
|
||||||
|
surfacePressure = 1013.25,
|
||||||
|
windSpeed = 5.0,
|
||||||
|
windDirection = 180.0,
|
||||||
|
windGusts = 10.0,
|
||||||
|
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
|
||||||
|
isForecast = false,
|
||||||
|
isNight = false
|
||||||
|
),
|
||||||
|
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
|
||||||
|
timezone = "UTC",
|
||||||
|
elevation = null,
|
||||||
|
forecasts = weatherData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
emit(
|
||||||
|
StreamData(
|
||||||
|
WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data),
|
||||||
|
SettingsAndProfile(
|
||||||
|
HeadwindSettings(),
|
||||||
|
settingsAndProfile?.isImperial == true,
|
||||||
|
settingsAndProfile?.isImperialTemperature == true
|
||||||
|
),
|
||||||
|
isVisible = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delay(5_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||||
|
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
|
||||||
|
Log.d(KarooHeadwindExtension.TAG, "Starting weather forecast view with $emitter")
|
||||||
|
val configJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
emitter.onNext(UpdateGraphicConfig(showHeader = false))
|
||||||
|
awaitCancellation()
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile ->
|
||||||
|
SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL,
|
||||||
|
isImperialTemperature = userProfile.preferredUnit.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataFlow = if (config.preview){
|
||||||
|
previewFlow(settingsAndProfileStream)
|
||||||
|
} else {
|
||||||
|
combine(
|
||||||
|
context.streamCurrentForecastWeatherData(),
|
||||||
|
settingsAndProfileStream,
|
||||||
|
context.streamWidgetSettings(),
|
||||||
|
karooSystem.getHeadingFlow(context).throttle(3 * 60_000L),
|
||||||
|
karooSystem.streamUpcomingRoute().distinctUntilChanged { old, new ->
|
||||||
|
val oldDistance = old?.distanceAlongRoute
|
||||||
|
val newDistance = new?.distanceAlongRoute
|
||||||
|
|
||||||
|
if (oldDistance == null && newDistance == null) return@distinctUntilChanged true
|
||||||
|
if (oldDistance == null || newDistance == null) return@distinctUntilChanged false
|
||||||
|
|
||||||
|
abs(oldDistance - newDistance) < 1_000
|
||||||
|
},
|
||||||
|
karooSystem.streamDatatypeIsVisible(dataTypeId)
|
||||||
|
) { data ->
|
||||||
|
val weatherData = data[0] as WeatherDataResponse?
|
||||||
|
val settings = data[1] as SettingsAndProfile
|
||||||
|
val widgetSettings = data[2] as HeadwindWidgetSettings?
|
||||||
|
val heading = data[3] as HeadingResponse?
|
||||||
|
val upcomingRoute = data[4] as UpcomingRoute?
|
||||||
|
val isVisible = data[5] as Boolean
|
||||||
|
|
||||||
|
StreamData(
|
||||||
|
data = weatherData,
|
||||||
|
settings = settings,
|
||||||
|
widgetSettings = widgetSettings,
|
||||||
|
headingResponse = heading,
|
||||||
|
upcomingRoute = upcomingRoute,
|
||||||
|
isVisible = isVisible
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
emitter.onNext(ShowCustomStreamState("", null))
|
||||||
|
|
||||||
|
dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) ->
|
||||||
|
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
|
||||||
|
|
||||||
|
if (allData?.data.isNullOrEmpty()){
|
||||||
|
emitter.updateView(
|
||||||
|
getErrorWidget(
|
||||||
|
glance,
|
||||||
|
context,
|
||||||
|
settingsAndProfile.settings,
|
||||||
|
headingResponse
|
||||||
|
).remoteViews)
|
||||||
|
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = glance.compose(context, DpSize.Unspecified) {
|
||||||
|
|
||||||
|
val data = buildList {
|
||||||
|
for(i in 0..12){
|
||||||
|
val locationData = allData?.data?.getOrNull(i) ?: allData?.data?.lastOrNull()
|
||||||
|
val data = locationData?.forecasts?.getOrNull(i)
|
||||||
|
if (data == null) {
|
||||||
|
Log.w(KarooHeadwindExtension.TAG, "No weather data available for forecast index $i")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val time = Instant.ofEpochSecond(data.time)
|
||||||
|
|
||||||
|
add(LineData(
|
||||||
|
time = time,
|
||||||
|
x = locationData.coords.distanceAlongRoute?.toFloat(),
|
||||||
|
weatherData = data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pointData = getLineData(data, settingsAndProfile.isImperialTemperature)
|
||||||
|
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
|
||||||
|
val addedHours = x / 60.0
|
||||||
|
val startTime = data.firstOrNull()?.time
|
||||||
|
val time = startTime?.plus(floor(addedHours * 60).toLong(), ChronoUnit.MINUTES)
|
||||||
|
val timeLabel = timeFormatter.format(time)
|
||||||
|
val beforeData = data.getOrNull(floor(x / 60.0).toInt().coerceAtLeast(0))
|
||||||
|
val afterData = data.getOrNull(ceil(x / 60.0).toInt())
|
||||||
|
|
||||||
|
if (beforeData?.x != null || afterData?.x != null) {
|
||||||
|
val start = (beforeData?.x ?: afterData?.x) ?: 0.0f
|
||||||
|
val end = (afterData?.x ?: beforeData?.x) ?: 0.0f
|
||||||
|
val distance = start + (end - start) * ((x / 60) - floor(x / 60))
|
||||||
|
val distanceLabel = if (settingsAndProfile.isImperial) {
|
||||||
|
"${(distance * 0.000621371).toInt()}mi"
|
||||||
|
} else {
|
||||||
|
"${(distance / 1000).toInt()}km"
|
||||||
|
}
|
||||||
|
return@drawLineGraph distanceLabel
|
||||||
|
} else {
|
||||||
|
timeLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = GlanceModifier.fillMaxSize()){
|
||||||
|
Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.updateView(result.remoteViews)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitter.setCancellable {
|
||||||
|
Log.d(
|
||||||
|
KarooHeadwindExtension.TAG,
|
||||||
|
"Stopping headwind weather forecast view with $emitter"
|
||||||
|
)
|
||||||
|
configJob.cancel()
|
||||||
|
viewJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,108 +1,27 @@
|
|||||||
package de.timklge.karooheadwind.datatypes
|
package de.timklge.karooheadwind.datatypes
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import de.timklge.karooheadwind.screens.LineGraphBuilder
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.TextUnitType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.glance.GlanceModifier
|
|
||||||
import androidx.glance.color.ColorProvider
|
|
||||||
import androidx.glance.layout.Alignment
|
|
||||||
import androidx.glance.layout.Column
|
|
||||||
import androidx.glance.layout.Row
|
|
||||||
import androidx.glance.layout.fillMaxHeight
|
|
||||||
import androidx.glance.layout.fillMaxWidth
|
|
||||||
import androidx.glance.layout.padding
|
|
||||||
import androidx.glance.layout.width
|
|
||||||
import androidx.glance.text.FontFamily
|
|
||||||
import androidx.glance.text.FontWeight
|
|
||||||
import androidx.glance.text.Text
|
|
||||||
import androidx.glance.text.TextAlign
|
|
||||||
import androidx.glance.text.TextStyle
|
|
||||||
import de.timklge.karooheadwind.TemperatureUnit
|
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
|
|
||||||
import io.hammerhead.karooext.KarooSystemService
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import kotlin.math.ceil
|
|
||||||
|
|
||||||
@Composable
|
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "precipitationForecast") {
|
||||||
fun PrecipitationForecast(
|
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
|
||||||
precipitation: Int,
|
val precipitationPoints = lineData.map { data ->
|
||||||
precipitationProbability: Int?,
|
if (isImperial) { // Convert mm to inches
|
||||||
distance: Double? = null,
|
data.weatherData.precipitation * 0.0393701 // Convert mm to inches
|
||||||
timeLabel: String? = null,
|
|
||||||
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
|
|
||||||
isImperial: Boolean?,
|
|
||||||
) {
|
|
||||||
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
|
|
||||||
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
val precipitationProbabilityText = if (precipitationProbability != null) "${precipitationProbability}% " else ""
|
|
||||||
val precipitationText = precipitation.toString()
|
|
||||||
Text(
|
|
||||||
text = "${precipitationProbabilityText}${precipitationText}",
|
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(24f, TextUnitType.Sp), textAlign = TextAlign.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distance != null && isImperial != null){
|
|
||||||
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
|
|
||||||
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
|
|
||||||
val text = if(distanceInUserUnit > 0){
|
|
||||||
"In $label"
|
|
||||||
} else {
|
} else {
|
||||||
"$label ago"
|
data.weatherData.precipitation
|
||||||
}
|
|
||||||
|
|
||||||
if (distanceInUserUnit != 0){
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
color = ColorProvider(Color.Black, Color.White),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = TextUnit(18f, TextUnitType.Sp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeLabel != null){
|
return setOf(
|
||||||
Text(
|
LineGraphBuilder.Line(
|
||||||
text = timeLabel,
|
dataPoints = precipitationPoints.mapIndexed { index, value ->
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
|
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
|
||||||
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
|
},
|
||||||
|
color = android.graphics.Color.BLUE,
|
||||||
|
label = if (!isImperial) "mm" else "in",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "precipitationForecast") {
|
|
||||||
@Composable
|
|
||||||
override fun RenderWidget(
|
|
||||||
arrowBitmap: Bitmap,
|
|
||||||
current: WeatherInterpretation,
|
|
||||||
windBearing: Int,
|
|
||||||
windSpeed: Int,
|
|
||||||
windGusts: Int,
|
|
||||||
precipitation: Double,
|
|
||||||
precipitationProbability: Int?,
|
|
||||||
temperature: Int,
|
|
||||||
temperatureUnit: TemperatureUnit,
|
|
||||||
timeLabel: String,
|
|
||||||
dateLabel: String?,
|
|
||||||
distance: Double?,
|
|
||||||
isImperial: Boolean,
|
|
||||||
isNight: Boolean,
|
|
||||||
) {
|
|
||||||
PrecipitationForecast(
|
|
||||||
precipitation = ceil(precipitation).toInt(),
|
|
||||||
precipitationProbability = precipitationProbability,
|
|
||||||
distance = distance,
|
|
||||||
timeLabel = timeLabel,
|
|
||||||
isImperial = isImperial,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,106 +1,28 @@
|
|||||||
package de.timklge.karooheadwind.datatypes
|
package de.timklge.karooheadwind.datatypes
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import de.timklge.karooheadwind.screens.LineGraphBuilder
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.TextUnitType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.glance.GlanceModifier
|
|
||||||
import androidx.glance.color.ColorProvider
|
|
||||||
import androidx.glance.layout.Alignment
|
|
||||||
import androidx.glance.layout.Column
|
|
||||||
import androidx.glance.layout.Row
|
|
||||||
import androidx.glance.layout.fillMaxHeight
|
|
||||||
import androidx.glance.layout.fillMaxWidth
|
|
||||||
import androidx.glance.layout.padding
|
|
||||||
import androidx.glance.layout.width
|
|
||||||
import androidx.glance.text.FontFamily
|
|
||||||
import androidx.glance.text.FontWeight
|
|
||||||
import androidx.glance.text.Text
|
|
||||||
import androidx.glance.text.TextAlign
|
|
||||||
import androidx.glance.text.TextStyle
|
|
||||||
import de.timklge.karooheadwind.TemperatureUnit
|
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
|
|
||||||
import io.hammerhead.karooext.KarooSystemService
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
@Composable
|
class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "temperatureForecast") {
|
||||||
fun TemperatureForecast(
|
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
|
||||||
temperature: Int,
|
val linePoints = lineData.map { data ->
|
||||||
temperatureUnit: TemperatureUnit,
|
if (isImperial) {
|
||||||
distance: Double? = null,
|
data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit
|
||||||
timeLabel: String? = null,
|
|
||||||
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
|
|
||||||
isImperial: Boolean?
|
|
||||||
) {
|
|
||||||
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
|
|
||||||
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "${temperature}${temperatureUnit.unitDisplay}",
|
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(28f, TextUnitType.Sp), textAlign = TextAlign.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distance != null && isImperial != null){
|
|
||||||
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
|
|
||||||
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
|
|
||||||
val text = if(distanceInUserUnit > 0){
|
|
||||||
"In $label"
|
|
||||||
} else {
|
} else {
|
||||||
"$label ago"
|
data.weatherData.temperature // Keep Celsius
|
||||||
}
|
|
||||||
|
|
||||||
if (distanceInUserUnit != 0){
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
color = ColorProvider(Color.Black, Color.White),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = TextUnit(18f, TextUnitType.Sp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeLabel != null){
|
return setOf(
|
||||||
Text(
|
LineGraphBuilder.Line(
|
||||||
text = timeLabel,
|
dataPoints = linePoints.mapIndexed { index, value ->
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
|
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
|
||||||
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
|
},
|
||||||
|
color = android.graphics.Color.RED,
|
||||||
|
label = if (!isImperial) "°C" else "°F",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TemperatureForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "temperatureForecast") {
|
|
||||||
@Composable
|
|
||||||
override fun RenderWidget(
|
|
||||||
arrowBitmap: Bitmap,
|
|
||||||
current: WeatherInterpretation,
|
|
||||||
windBearing: Int,
|
|
||||||
windSpeed: Int,
|
|
||||||
windGusts: Int,
|
|
||||||
precipitation: Double,
|
|
||||||
precipitationProbability: Int?,
|
|
||||||
temperature: Int,
|
|
||||||
temperatureUnit: TemperatureUnit,
|
|
||||||
timeLabel: String,
|
|
||||||
dateLabel: String?,
|
|
||||||
distance: Double?,
|
|
||||||
isImperial: Boolean,
|
|
||||||
isNight: Boolean
|
|
||||||
) {
|
|
||||||
TemperatureForecast(
|
|
||||||
temperature = temperature,
|
|
||||||
temperatureUnit = temperatureUnit,
|
|
||||||
distance = distance,
|
|
||||||
timeLabel = timeLabel,
|
|
||||||
isImperial = isImperial,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,119 +1,42 @@
|
|||||||
package de.timklge.karooheadwind.datatypes
|
package de.timklge.karooheadwind.datatypes
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import de.timklge.karooheadwind.screens.LineGraphBuilder
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.TextUnitType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.glance.ColorFilter
|
|
||||||
import androidx.glance.GlanceModifier
|
|
||||||
import androidx.glance.Image
|
|
||||||
import androidx.glance.ImageProvider
|
|
||||||
import androidx.glance.color.ColorProvider
|
|
||||||
import androidx.glance.layout.Alignment
|
|
||||||
import androidx.glance.layout.Column
|
|
||||||
import androidx.glance.layout.ContentScale
|
|
||||||
import androidx.glance.layout.Row
|
|
||||||
import androidx.glance.layout.fillMaxHeight
|
|
||||||
import androidx.glance.layout.fillMaxWidth
|
|
||||||
import androidx.glance.layout.padding
|
|
||||||
import androidx.glance.layout.width
|
|
||||||
import androidx.glance.text.FontFamily
|
|
||||||
import androidx.glance.text.FontWeight
|
|
||||||
import androidx.glance.text.Text
|
|
||||||
import androidx.glance.text.TextAlign
|
|
||||||
import androidx.glance.text.TextStyle
|
|
||||||
import de.timklge.karooheadwind.TemperatureUnit
|
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
|
|
||||||
import io.hammerhead.karooext.KarooSystemService
|
import io.hammerhead.karooext.KarooSystemService
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
@Composable
|
class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "windForecast") {
|
||||||
fun WindForecast(
|
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
|
||||||
arrowBitmap: Bitmap,
|
val windPoints = lineData.map { data ->
|
||||||
windBearing: Int,
|
if (isImperial) { // Convert m/s to mph
|
||||||
windSpeed: Int,
|
data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
|
||||||
gustSpeed: Int,
|
} else { // Convert m/s to km/h
|
||||||
distance: Double? = null,
|
data.weatherData.windSpeed * 3.6 // Convert m/s to km/h
|
||||||
timeLabel: String? = null,
|
|
||||||
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
|
|
||||||
isImperial: Boolean?
|
|
||||||
) {
|
|
||||||
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
|
|
||||||
Image(
|
|
||||||
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
|
|
||||||
provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)),
|
|
||||||
contentDescription = "Current wind direction",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${windSpeed}-${gustSpeed}",
|
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp), textAlign = TextAlign.Center)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (distance != null && isImperial != null){
|
|
||||||
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
|
|
||||||
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
|
|
||||||
val text = if(distanceInUserUnit > 0){
|
|
||||||
"In $label"
|
|
||||||
} else {
|
|
||||||
"$label ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distanceInUserUnit != 0){
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
color = ColorProvider(Color.Black, Color.White),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = TextUnit(18f, TextUnitType.Sp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeLabel != null){
|
val gustPoints = lineData.map { data ->
|
||||||
Text(
|
if (isImperial) { // Convert m/s to mph
|
||||||
text = timeLabel,
|
data.weatherData.windGusts * 2.23694 // Convert m/s to mph
|
||||||
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
|
} else { // Convert m/s to km/h
|
||||||
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
|
data.weatherData.windGusts * 3.6 // Convert m/s to km/h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return setOf(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
LineGraphBuilder.Line(
|
||||||
|
dataPoints = gustPoints.mapIndexed { index, value ->
|
||||||
|
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
|
||||||
|
},
|
||||||
|
color = android.graphics.Color.DKGRAY,
|
||||||
|
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WindForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "windForecast") {
|
|
||||||
@Composable
|
|
||||||
override fun RenderWidget(
|
|
||||||
arrowBitmap: Bitmap,
|
|
||||||
current: WeatherInterpretation,
|
|
||||||
windBearing: Int,
|
|
||||||
windSpeed: Int,
|
|
||||||
windGusts: Int,
|
|
||||||
precipitation: Double,
|
|
||||||
precipitationProbability: Int?,
|
|
||||||
temperature: Int,
|
|
||||||
temperatureUnit: TemperatureUnit,
|
|
||||||
timeLabel: String,
|
|
||||||
dateLabel: String?,
|
|
||||||
distance: Double?,
|
|
||||||
isImperial: Boolean,
|
|
||||||
isNight: Boolean
|
|
||||||
) {
|
|
||||||
WindForecast(
|
|
||||||
arrowBitmap = arrowBitmap,
|
|
||||||
windBearing = windBearing,
|
|
||||||
windSpeed = windSpeed,
|
|
||||||
gustSpeed = windGusts,
|
|
||||||
distance = distance,
|
|
||||||
timeLabel = timeLabel,
|
|
||||||
isImperial = isImperial,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,320 @@
|
|||||||
|
package de.timklge.karooheadwind.screens
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Paint.Align
|
||||||
|
import android.graphics.Path
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class LineGraphBuilder(val context: Context) {
|
||||||
|
data class DataPoint(val x: Float, val y: Float)
|
||||||
|
|
||||||
|
data class AxisLabel(val x: Float, val label: String)
|
||||||
|
|
||||||
|
data class Line(
|
||||||
|
val dataPoints: List<DataPoint>,
|
||||||
|
@ColorInt val color: Int,
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, gridWidth: Int, gridHeight: Int, lines: Set<Line>, labelProvider: ((Float) -> String)): Bitmap {
|
||||||
|
val isNightMode = isNightMode()
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
|
||||||
|
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
|
||||||
|
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
|
||||||
|
val secondaryTextColor = if (isNightMode) Color.LTGRAY else Color.DKGRAY // For axes
|
||||||
|
|
||||||
|
canvas.drawColor(backgroundColor)
|
||||||
|
|
||||||
|
if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) {
|
||||||
|
val emptyPaint = Paint().apply {
|
||||||
|
color = primaryTextColor
|
||||||
|
textSize = 20f
|
||||||
|
textAlign = Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
val marginTop = 10f
|
||||||
|
val marginBottom = 40f // Increased from 30f
|
||||||
|
val marginRight = 20f // Increased from 5f
|
||||||
|
|
||||||
|
var dataMinX = Float.MAX_VALUE
|
||||||
|
var dataMaxX = Float.MIN_VALUE
|
||||||
|
var dataMinY = Float.MAX_VALUE
|
||||||
|
var dataMaxY = Float.MIN_VALUE
|
||||||
|
|
||||||
|
var hasData = false
|
||||||
|
lines.forEach { line ->
|
||||||
|
if (line.dataPoints.isNotEmpty()) hasData = true
|
||||||
|
line.dataPoints.forEach { point ->
|
||||||
|
dataMinX = minOf(dataMinX, point.x)
|
||||||
|
dataMaxX = maxOf(dataMaxX, point.x)
|
||||||
|
dataMinY = minOf(dataMinY, point.y)
|
||||||
|
dataMaxY = maxOf(dataMaxY, point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasData) {
|
||||||
|
val emptyPaint = Paint().apply {
|
||||||
|
color = primaryTextColor
|
||||||
|
textSize = 40f
|
||||||
|
textAlign = Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
canvas.drawText("No data points", width / 2f, height / 2f, emptyPaint)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically calculate marginLeft based on Y-axis label widths
|
||||||
|
val yAxisLabelPaint = Paint().apply {
|
||||||
|
textSize = 28f // Matches textPaint for Y-axis labels
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxLabelWidth = 0f
|
||||||
|
val yLabelStrings = mutableListOf<String>()
|
||||||
|
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
|
||||||
|
|
||||||
|
// Determine Y-axis label strings (mirrors logic from where labels are drawn)
|
||||||
|
if (kotlin.math.abs(dataMaxY - dataMinY) < 0.0001f) {
|
||||||
|
yLabelStrings.add(String.format(java.util.Locale.getDefault(), "%.0f", dataMinY))
|
||||||
|
} else {
|
||||||
|
for (i in 0..numYTicksForCalc) {
|
||||||
|
val value = dataMinY + ((dataMaxY - dataMinY) / numYTicksForCalc) * i
|
||||||
|
yLabelStrings.add(String.format(java.util.Locale.getDefault(), "%.0f", value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (labelStr in yLabelStrings) {
|
||||||
|
maxLabelWidth = kotlin.math.max(maxLabelWidth, yAxisLabelPaint.measureText(labelStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
val yAxisTextRightToAxisGap = 15f // Current gap used: graphLeft - 15f
|
||||||
|
val canvasEdgePadding = 5f // Desired padding from the canvas edge
|
||||||
|
|
||||||
|
val dynamicMarginLeft = maxLabelWidth + yAxisTextRightToAxisGap + canvasEdgePadding
|
||||||
|
|
||||||
|
val marginLeft = dynamicMarginLeft
|
||||||
|
|
||||||
|
val graphWidth = width - marginLeft - marginRight
|
||||||
|
val graphHeight = height - marginTop - marginBottom
|
||||||
|
val graphLeft = marginLeft
|
||||||
|
val graphTop = marginTop
|
||||||
|
val graphBottom = height - marginBottom
|
||||||
|
|
||||||
|
// Legend properties
|
||||||
|
val legendTextSize = 22f // Increased from 18f
|
||||||
|
val legendTextColor = primaryTextColor
|
||||||
|
val legendPadding = 5f
|
||||||
|
val legendEntryHeight = 30f // Increased from 25f
|
||||||
|
val legendColorBoxSize = 24f // Increased from 20f
|
||||||
|
val legendTextMargin = 5f
|
||||||
|
|
||||||
|
var effectiveMinX = dataMinX
|
||||||
|
var effectiveMaxX = dataMaxX
|
||||||
|
var effectiveMinY = dataMinY
|
||||||
|
var effectiveMaxY = dataMaxY
|
||||||
|
|
||||||
|
if (dataMinX == dataMaxX) {
|
||||||
|
effectiveMinX -= 1f
|
||||||
|
effectiveMaxX += 1f
|
||||||
|
} else {
|
||||||
|
val paddingX = (dataMaxX - dataMinX) * 0.05f
|
||||||
|
if (paddingX > 0.0001f) {
|
||||||
|
effectiveMinX -= paddingX
|
||||||
|
effectiveMaxX += paddingX
|
||||||
|
} else {
|
||||||
|
effectiveMinX -= 1f
|
||||||
|
effectiveMaxX += 1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataMinY == dataMaxY) {
|
||||||
|
effectiveMinY -= 1f
|
||||||
|
effectiveMaxY += 1f
|
||||||
|
} else {
|
||||||
|
val paddingY = (dataMaxY - dataMinY) * 0.05f
|
||||||
|
if (paddingY > 0.0001f) {
|
||||||
|
effectiveMinY -= paddingY
|
||||||
|
effectiveMaxY += paddingY
|
||||||
|
} else {
|
||||||
|
effectiveMinY -= 1f
|
||||||
|
effectiveMaxY += 1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rangeX = if (abs(effectiveMaxX - effectiveMinX) < 0.0001f) 1f else (effectiveMaxX - effectiveMinX)
|
||||||
|
val rangeY = if (abs(effectiveMaxY - effectiveMinY) < 0.0001f) 1f else (effectiveMaxY - effectiveMinY)
|
||||||
|
|
||||||
|
fun mapX(originalX: Float): Float {
|
||||||
|
return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapY(originalY: Float): Float {
|
||||||
|
return graphBottom - ((originalY - effectiveMinY) / rangeY) * graphHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
val axisPaint = Paint().apply {
|
||||||
|
color = secondaryTextColor
|
||||||
|
strokeWidth = 3f
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
canvas.drawLine(graphLeft, graphBottom, graphLeft + graphWidth, graphBottom, axisPaint)
|
||||||
|
canvas.drawLine(graphLeft, graphTop, graphLeft, graphBottom, axisPaint)
|
||||||
|
|
||||||
|
val linePaint = Paint().apply {
|
||||||
|
strokeWidth = 6f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
isAntiAlias = true
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeJoin = Paint.Join.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
val textPaint = Paint().apply {
|
||||||
|
color = primaryTextColor
|
||||||
|
textSize = 28f
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (line in lines) {
|
||||||
|
if (line.dataPoints.isEmpty()) continue
|
||||||
|
|
||||||
|
linePaint.color = line.color
|
||||||
|
val path = Path()
|
||||||
|
val firstPoint = line.dataPoints.first()
|
||||||
|
path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y))
|
||||||
|
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) {
|
||||||
|
val point = line.dataPoints[i]
|
||||||
|
path.lineTo(mapX(point.x), mapY(point.y))
|
||||||
|
canvas.drawCircle(mapX(point.x), mapY(point.y), 8f, linePaint.apply { style = Paint.Style.FILL })
|
||||||
|
linePaint.style = Paint.Style.STROKE
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, linePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
textPaint.textAlign = Align.RIGHT
|
||||||
|
val numYTicks = if (gridWidth > 15) 2 else 1
|
||||||
|
if (abs(dataMaxY - dataMinY) > 0.0001f) {
|
||||||
|
for (i in 0..numYTicks) {
|
||||||
|
val value = dataMinY + ((dataMaxY - dataMinY) / numYTicks) * i
|
||||||
|
val yPos = mapY(value)
|
||||||
|
if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) {
|
||||||
|
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
|
||||||
|
canvas.drawText(String.format(java.util.Locale.getDefault(), "%.0f", value), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint) // Adjusted horizontal offset from -10f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val yPos = mapY(dataMinY)
|
||||||
|
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
|
||||||
|
canvas.drawText(String.format(java.util.Locale.getDefault(), "%.0f", dataMinY), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint) // Adjusted horizontal offset from -10f
|
||||||
|
}
|
||||||
|
|
||||||
|
textPaint.textAlign = Align.CENTER
|
||||||
|
val numXTicks = if (gridHeight > 15) 3 else 1
|
||||||
|
if (abs(dataMaxX - dataMinX) > 0.0001f) {
|
||||||
|
for (i in 0..numXTicks) {
|
||||||
|
val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i
|
||||||
|
val xPos = mapX(value)
|
||||||
|
if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) {
|
||||||
|
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
|
||||||
|
canvas.drawText(labelProvider(xPos), xPos, graphBottom + 30f, textPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val xPos = mapX(dataMinX)
|
||||||
|
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
|
||||||
|
canvas.drawText(labelProvider(xPos), xPos, graphBottom + 30f, textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
textPaint.textAlign = Align.CENTER
|
||||||
|
textPaint.color = primaryTextColor // Ensure textPaint color is reset before drawing legend
|
||||||
|
|
||||||
|
// Draw Legend
|
||||||
|
val legendPaint = Paint().apply {
|
||||||
|
textSize = legendTextSize
|
||||||
|
color = legendTextColor
|
||||||
|
isAntiAlias = true
|
||||||
|
textAlign = Align.LEFT // Important for measuring text width correctly
|
||||||
|
}
|
||||||
|
val legendColorPaint = Paint().apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val legendItems = lines.filter { it.label != null }
|
||||||
|
if (legendItems.isNotEmpty()) {
|
||||||
|
var maxLegendLabelWidth = 0f
|
||||||
|
for (item in legendItems) {
|
||||||
|
maxLegendLabelWidth = kotlin.math.max(maxLegendLabelWidth, legendPaint.measureText(item.label!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
val legendContentActualLeft = (width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - maxLegendLabelWidth)
|
||||||
|
val legendContentActualRight = (width - marginRight - legendPadding) // Right edge of the color box
|
||||||
|
|
||||||
|
val legendContentActualTop = graphTop + legendPadding // Top edge of the first color box
|
||||||
|
val legendContentActualBottom = legendContentActualTop + (legendItems.size - 1) * legendEntryHeight + legendColorBoxSize // Bottom edge of the last color box
|
||||||
|
|
||||||
|
val legendBgPaint = Paint().apply {
|
||||||
|
color = if (isNightMode) {
|
||||||
|
Color.argb(200, 0, 0, 0)
|
||||||
|
} else {
|
||||||
|
Color.argb(200, 255, 255, 255)
|
||||||
|
}
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
canvas.drawRoundRect(
|
||||||
|
legendContentActualLeft,
|
||||||
|
legendContentActualTop,
|
||||||
|
legendContentActualRight,
|
||||||
|
legendContentActualBottom,
|
||||||
|
5f,
|
||||||
|
5f,
|
||||||
|
legendBgPaint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentLegendY = graphTop + legendPadding
|
||||||
|
|
||||||
|
for (line in legendItems) { // Use the filtered list, was: lines.filter { it.label != null }
|
||||||
|
// Draw color box
|
||||||
|
legendColorPaint.color = line.color
|
||||||
|
canvas.drawRect(
|
||||||
|
width - marginRight - legendPadding - legendColorBoxSize, // left
|
||||||
|
currentLegendY, // top
|
||||||
|
width - marginRight - legendPadding, // right
|
||||||
|
currentLegendY + legendColorBoxSize, // bottom
|
||||||
|
legendColorPaint
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw label text
|
||||||
|
canvas.drawText(
|
||||||
|
line.label!!,
|
||||||
|
width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - legendPaint.measureText(line.label), // x: Align text to the left of the color box
|
||||||
|
currentLegendY + legendColorBoxSize / 2 + legendTextSize / 3, // y: Vertically center text with color box
|
||||||
|
legendPaint
|
||||||
|
)
|
||||||
|
currentLegendY += legendEntryHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user