mirror of
https://github.com/ev-map/EVMap.git
synced 2026-04-30 11:04:16 -04:00
@@ -137,6 +137,7 @@ data class ChargeLocationStatus(
|
||||
val status: Map<Chargepoint, List<ChargepointStatus>>,
|
||||
val source: String,
|
||||
val evseIds: Map<Chargepoint, List<String>>? = null,
|
||||
val congestionHistogram: List<Double>? = null,
|
||||
val extraData: Any? = null // API-specific data
|
||||
) {
|
||||
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
import java.util.Collections
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
|
||||
@@ -277,10 +278,10 @@ interface TeslaGraphQlApi {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteInformation(
|
||||
// TODO: congestionPriceHistogram, pricing
|
||||
val siteDynamic: SiteDynamic,
|
||||
val siteStatic: SiteStatic,
|
||||
val pricing: Pricing
|
||||
val pricing: Pricing,
|
||||
val congestionPriceHistogram: CongestionPriceHistogram,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -380,6 +381,18 @@ interface TeslaGraphQlApi {
|
||||
val rates: List<Double>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CongestionPriceHistogram(
|
||||
val data: List<Double>,
|
||||
val dataAttributes: List<CongestionHistogramDataAttributes>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CongestionHistogramDataAttributes(
|
||||
val congestionThreshold: String, // "LEVEL_1"
|
||||
val label: String // "1AM", "2AM", etc.
|
||||
)
|
||||
|
||||
enum class ChargerAvailability {
|
||||
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
|
||||
AVAILABLE,
|
||||
@@ -561,7 +574,20 @@ class TeslaAvailabilityDetector(
|
||||
i += connector.count
|
||||
}
|
||||
|
||||
return ChargeLocationStatus(statusMap, "Tesla", extraData = details.pricing)
|
||||
val indexOfMidnight =
|
||||
details.congestionPriceHistogram.dataAttributes.indexOfFirst { it.label == "12AM" }
|
||||
val congestionHistogram = indexOfMidnight.takeIf { it >= 0 }?.let { index ->
|
||||
val data = details.congestionPriceHistogram.data.toMutableList()
|
||||
Collections.rotate(data, -index)
|
||||
data
|
||||
}
|
||||
|
||||
return ChargeLocationStatus(
|
||||
statusMap,
|
||||
"Tesla",
|
||||
congestionHistogram = congestionHistogram,
|
||||
extraData = details.pricing
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import net.vonforst.evmap.R
|
||||
import java.time.Duration
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -28,8 +29,8 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
private val dp = context.resources.displayMetrics.density
|
||||
private val sp = context.resources.displayMetrics.scaledDensity
|
||||
var zeroHeight = 4 * dp
|
||||
var barWidth = (16 * dp).roundToInt()
|
||||
var barMargin = (2 * dp).roundToInt()
|
||||
var barWidth = 16 * dp
|
||||
var barMargin = 2 * dp
|
||||
var legendWidth = 12 * dp
|
||||
var legendLineLength = 4 * dp
|
||||
var legendLineWidth = 1 * dp
|
||||
@@ -42,18 +43,20 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
var barDrawable =
|
||||
AppCompatResources.getDrawable(context, R.drawable.bar_graph)!!
|
||||
var colorAvailable = ContextCompat.getColor(context, R.color.available)
|
||||
var colorSomeAvailable = ContextCompat.getColor(context, R.color.some_available)
|
||||
var colorUnavailable = ContextCompat.getColor(context, R.color.unavailable)
|
||||
|
||||
var data: Map<ZonedDateTime, Int>? = null
|
||||
var data: Map<ZonedDateTime, Double>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
var maxValue: Int? = null
|
||||
var maxValue: Double? = null
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
var isPercentage: Boolean = false
|
||||
|
||||
var activeAlpha = 0.87f
|
||||
var inactiveAlpha = 0.60f
|
||||
@@ -110,22 +113,28 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
plusMinutes((minutesRound - minute).toLong())
|
||||
}
|
||||
data = (0..20).associate {
|
||||
now.plusMinutes(15L * it) to (Math.random() * 8).roundToInt()
|
||||
now.plusMinutes(15L * it) to (Math.random() * 8)
|
||||
}
|
||||
maxValue = 8
|
||||
maxValue = 8.0
|
||||
}
|
||||
val data = data?.toSortedMap() ?: return
|
||||
if (data.isEmpty()) return
|
||||
val maxValue = maxValue ?: data.maxOf { it.value }
|
||||
|
||||
val graphWidth = graphBounds?.width() ?: return
|
||||
val n = data.size
|
||||
val barMarginFactor = 0.1f
|
||||
barWidth = graphWidth / (n + barMarginFactor * (n - 1))
|
||||
barMargin = barWidth * barMarginFactor
|
||||
|
||||
drawGraph(canvas, data, maxValue)
|
||||
drawBubble(canvas, data, maxValue)
|
||||
}
|
||||
|
||||
private fun drawGraph(
|
||||
canvas: Canvas,
|
||||
data: SortedMap<ZonedDateTime, Int>,
|
||||
maxValue: Int
|
||||
data: SortedMap<ZonedDateTime, Double>,
|
||||
maxValue: Double
|
||||
) {
|
||||
val graphBounds = graphBounds ?: return
|
||||
|
||||
@@ -139,26 +148,30 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
)
|
||||
|
||||
legendPaint.textAlign = Paint.Align.CENTER
|
||||
|
||||
data.entries.forEachIndexed { i, (t, v) ->
|
||||
val height =
|
||||
zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue
|
||||
zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue.toFloat()
|
||||
val left = graphBounds.left + (barWidth + barMargin) * i
|
||||
|
||||
if (left + barWidth > graphBounds.right) return@forEachIndexed
|
||||
|
||||
barDrawable.setBounds(
|
||||
left,
|
||||
0,
|
||||
graphBounds.bottom - height.roundToInt(),
|
||||
left + barWidth,
|
||||
barWidth.roundToInt(),
|
||||
graphBounds.bottom
|
||||
)
|
||||
|
||||
canvas.translate(left, 0f)
|
||||
barDrawable.alpha =
|
||||
((if (i == selectedBar) activeAlpha else inactiveAlpha) * 255).roundToInt()
|
||||
barDrawable.setTint(getColor(v, maxValue))
|
||||
barDrawable.draw(canvas)
|
||||
canvas.translate(-left, 0f)
|
||||
|
||||
val center = left.toFloat() + barWidth / 2
|
||||
if (t.minute == 0) {
|
||||
val center = left + barWidth / 2
|
||||
if (shouldDrawLabel(t, data)) {
|
||||
drawLine(
|
||||
center, graphBounds.bottom.toFloat(),
|
||||
center, graphBounds.bottom + legendLineLength, legendPaint
|
||||
@@ -196,19 +209,44 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
)
|
||||
|
||||
legendPaint.textAlign = Paint.Align.LEFT
|
||||
drawText(
|
||||
this@BarGraphView.maxValue.toString(),
|
||||
graphBounds.right.toFloat() + legendLineLength,
|
||||
graphBounds.top + (legendWidth - legendLineLength) / 3,
|
||||
legendPaint
|
||||
)
|
||||
if (!isPercentage) {
|
||||
drawText(
|
||||
maxValue.roundToInt().toString(),
|
||||
graphBounds.right.toFloat() + legendLineLength,
|
||||
graphBounds.top + (legendWidth - legendLineLength) / 3,
|
||||
legendPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getColor(v: Int, maxValue: Int) =
|
||||
if (v < maxValue) colorAvailable else colorUnavailable
|
||||
private fun shouldDrawLabel(t: ZonedDateTime, data: SortedMap<ZonedDateTime, Double>): Boolean {
|
||||
val ts = data.keys.toList()
|
||||
return if (Duration.between(ts[0], ts[1]) > Duration.ofMinutes(31)) {
|
||||
// label every 6 hours
|
||||
t.hour % 6 == 0
|
||||
} else {
|
||||
// label every 15 minutes
|
||||
t.minute == 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBubble(canvas: Canvas, data: SortedMap<ZonedDateTime, Int>, maxValue: Int) {
|
||||
private fun getColor(v: Double, maxValue: Double) =
|
||||
if (isPercentage) {
|
||||
when (v) {
|
||||
in 0.0..0.5 -> colorAvailable
|
||||
in 0.5..0.8 -> colorSomeAvailable
|
||||
else -> colorUnavailable
|
||||
}
|
||||
} else {
|
||||
if (v < maxValue) colorAvailable else colorUnavailable
|
||||
}
|
||||
|
||||
private fun drawBubble(
|
||||
canvas: Canvas,
|
||||
data: SortedMap<ZonedDateTime, Double>,
|
||||
maxValue: Double
|
||||
) {
|
||||
val bubbleBounds = bubbleBounds ?: return
|
||||
val graphBounds = graphBounds ?: return
|
||||
val d = data.toList()
|
||||
@@ -221,12 +259,16 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
R.string.prediction_time_colon,
|
||||
t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat)
|
||||
)
|
||||
val availableformat = context.resources.getQuantityString(
|
||||
R.plurals.prediction_number_available,
|
||||
maxValue - v,
|
||||
maxValue - v,
|
||||
maxValue
|
||||
)
|
||||
val availableformat = if (isPercentage) {
|
||||
"%.0f %%".format(v * 100)
|
||||
} else {
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.prediction_number_available,
|
||||
(maxValue - v).roundToInt(),
|
||||
(maxValue - v).roundToInt(),
|
||||
maxValue.roundToInt()
|
||||
)
|
||||
}
|
||||
val text = SpannableString("$tformat $availableformat").apply {
|
||||
setSpan(
|
||||
ForegroundColorSpan(getColor(v, maxValue)),
|
||||
@@ -297,7 +339,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
|
||||
private fun updateSelectedBar(x: Int) {
|
||||
val graphBounds = graphBounds ?: return
|
||||
val bar = (x - graphBounds.left) / (barWidth + barMargin)
|
||||
val bar = ((x - graphBounds.left) / (barWidth + barMargin)).roundToInt()
|
||||
if (bar != selectedBar) {
|
||||
selectedBar = bar
|
||||
invalidate()
|
||||
|
||||
@@ -420,4 +420,9 @@ fun setImageTint(view: ImageView, @ColorInt tint: Int?) {
|
||||
} else {
|
||||
view.imageTintList = null
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("isPercentage")
|
||||
fun setIsPercentage(view: BarGraphView, value: Boolean) {
|
||||
view.isPercentage = value
|
||||
}
|
||||
@@ -37,6 +37,9 @@ import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@Parcelize
|
||||
@@ -286,33 +289,45 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
}
|
||||
|
||||
val predictionGraph: LiveData<Map<ZonedDateTime, Int>?> by lazy {
|
||||
prediction.map {
|
||||
it.data?.let { responses ->
|
||||
if (responses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val evseIds = responses.map { it.evseId }
|
||||
val groupByTimestamp = responses.flatMap { response ->
|
||||
response.predictions.map {
|
||||
Triple(
|
||||
it.timestamp,
|
||||
response.evseId,
|
||||
it.status
|
||||
)
|
||||
val predictionGraph: LiveData<Map<ZonedDateTime, Double>?> =
|
||||
MediatorLiveData<Map<ZonedDateTime, Double>?>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
val congestionHistogram = availability.value?.data?.congestionHistogram
|
||||
val prediction = prediction.value?.data
|
||||
value = if (congestionHistogram != null && prediction == null) {
|
||||
congestionHistogram.mapIndexed { i, value ->
|
||||
LocalTime.of(i, 0).atDate(LocalDate.now())
|
||||
.atZone(ZoneId.systemDefault()) to value
|
||||
}.toMap()
|
||||
} else {
|
||||
prediction?.let { responses ->
|
||||
if (responses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val evseIds = responses.map { it.evseId }
|
||||
val groupByTimestamp = responses.flatMap { response ->
|
||||
response.predictions.map {
|
||||
Triple(
|
||||
it.timestamp,
|
||||
response.evseId,
|
||||
it.status
|
||||
)
|
||||
}
|
||||
}
|
||||
.groupBy { it.first } // group by timestamp
|
||||
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
|
||||
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
|
||||
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
|
||||
|
||||
groupByTimestamp.mapValues {
|
||||
it.value.count {
|
||||
it.second == FronyxStatus.UNAVAILABLE
|
||||
}.toDouble()
|
||||
}.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
.groupBy { it.first } // group by timestamp
|
||||
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
|
||||
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
|
||||
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
|
||||
|
||||
groupByTimestamp.mapValues {
|
||||
it.value.count {
|
||||
it.second == FronyxStatus.UNAVAILABLE
|
||||
}
|
||||
}.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,9 +347,25 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
}
|
||||
|
||||
val predictionMaxValue: LiveData<Int> by lazy {
|
||||
predictedChargepoints.map {
|
||||
it?.sumOf { it.count } ?: 0
|
||||
val predictionMaxValue: LiveData<Double> = MediatorLiveData<Double>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
value =
|
||||
if (availability.value?.data?.congestionHistogram != null && prediction.value?.data == null) {
|
||||
1.0
|
||||
} else {
|
||||
(predictedChargepoints.value?.sumOf { it.count } ?: 0).toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val predictionIsPercentage: LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
value =
|
||||
availability.value?.data?.congestionHistogram != null && prediction.value?.data == null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,15 @@
|
||||
|
||||
<variable
|
||||
name="predictionGraph"
|
||||
type="Map<ZonedDateTime, Integer>" />
|
||||
type="Map<ZonedDateTime, Double>" />
|
||||
|
||||
<variable
|
||||
name="predictionMaxValue"
|
||||
type="Integer" />
|
||||
type="Double" />
|
||||
|
||||
<variable
|
||||
name="predictionIsPercentage"
|
||||
type="Boolean" />
|
||||
|
||||
<variable
|
||||
name="predictionDescription"
|
||||
@@ -357,7 +361,8 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/utilization_prediction"
|
||||
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
|
||||
tools:text="@string/utilization_prediction"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
|
||||
android:textColor="?colorPrimary"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
@@ -372,7 +377,7 @@
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@{predictionDescription}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
|
||||
app:layout_constraintStart_toEndOf="@+id/textView8"
|
||||
@@ -384,7 +389,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/help"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:icon="@drawable/ic_help"
|
||||
app:iconTint="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/textView8"
|
||||
@@ -402,6 +407,7 @@
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView8"
|
||||
app:maxValue="@{predictionMaxValue}"
|
||||
app:isPercentage="@{predictionIsPercentage}"
|
||||
tools:itemCount="3"
|
||||
tools:layoutManager="LinearLayoutManager"
|
||||
tools:listitem="@layout/item_connector"
|
||||
@@ -415,7 +421,7 @@
|
||||
android:adjustViewBounds="true"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:scaleType="fitCenter"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/prediction"
|
||||
app:srcCompat="@drawable/ic_powered_by_fronyx"
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
app:filteredAvailability="@{vm.filteredAvailability}"
|
||||
app:predictionGraph="@{vm.predictionGraph}"
|
||||
app:predictionMaxValue="@{vm.predictionMaxValue}"
|
||||
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
|
||||
app:predictionDescription="@{vm.predictionDescription}"
|
||||
app:chargeCards="@{vm.chargeCardMap}"
|
||||
app:filteredChargeCards="@{vm.filteredChargeCards}"
|
||||
|
||||
@@ -312,4 +312,5 @@
|
||||
<string name="pricing_up_to">bis zu %s</string>
|
||||
<string name="tesla_pricing_other_times">Andere Zeiten:</string>
|
||||
<string name="tesla_pricing_blocking_fee">Blockiergebühr: %s</string>
|
||||
<string name="average_utilization">Durchschnittliche Auslastung</string>
|
||||
</resources>
|
||||
@@ -15,6 +15,7 @@
|
||||
<color name="charger_low">#607d8b</color>
|
||||
<color name="available">#4caf50</color>
|
||||
<color name="charging">#00bcd4</color>
|
||||
<color name="some_available">#ffc107</color>
|
||||
<color name="unavailable">#f44336</color>
|
||||
<color name="unknown">#9e9e9e</color>
|
||||
<color name="status_bar_scrim">#C3000000</color>
|
||||
|
||||
@@ -312,4 +312,5 @@
|
||||
<string name="pricing_up_to">up to %s</string>
|
||||
<string name="tesla_pricing_other_times">Other times:</string>
|
||||
<string name="tesla_pricing_blocking_fee">Blocking fee: %s</string>
|
||||
<string name="average_utilization">Average Utilization</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user