11package io.evcc.android.widget
22
3+ import android.content.Context
34import android.graphics.Bitmap
45import android.graphics.Canvas
56import android.graphics.Paint
67import android.graphics.Path
8+ import androidx.glance.color.isNightMode
79import java.text.SimpleDateFormat
810import java.util.Calendar
911import java.util.Date
1012import java.util.Locale
13+ import kotlin.math.ceil
14+
15+ /* * Mirrors ChartKind in Views.swift: area = solar (monotone-ish line + fill),
16+ * step = price/CO2 (stepEnd line, no fill), stepArea = feed-in (stepEnd + fill). */
17+ enum class ChartKind { AREA , STEP , STEP_AREA }
1118
1219/* *
13- * Renders a forecast series to a Bitmap (line + translucent area fill, with
14- * local-midnight day dividers and weekday labels), shown in the widget via a
20+ * Renders a forecast series to a Bitmap (line + optional area fill, a Y axis,
21+ * local-midnight day dividers, and weekday labels), shown in the widget via a
1522 * Glance Image. Glance has no chart primitive, so this Canvas bitmap is how we
16- * approximate the iOS Swift Charts look.
23+ * approximate the iOS Swift Charts look (see ForecastChart in Views.swift) -
24+ * straight line segments rather than Swift Charts' `.monotone` spline
25+ * smoothing is a known simplification.
1726 */
1827object ChartRenderer {
1928 private const val W = 720
2029 private const val H = 240
21-
22- private val green = 0xFF0FDE41 .toInt()
23- private val fill = 0x330FDE41 .toInt()
24- private val divider = 0x33FFFFFF .toInt()
25- private val labelColor = 0x99FFFFFF .toInt()
30+ private const val PAD_TOP = 18f
31+ private const val PAD_BOTTOM = 30f
32+ private const val PAD_LEFT = 32f
2633
2734 /* * values and times must be index-aligned; times in epoch millis (may be empty). */
28- fun render (values : List <Double >, times : List <Long >): Bitmap {
35+ fun render (
36+ context : Context ,
37+ values : List <Double >,
38+ times : List <Long >,
39+ kind : ChartKind ,
40+ accentDay : Int ,
41+ accentNight : Int ,
42+ ): Bitmap {
2943 val bmp = Bitmap .createBitmap(W , H , Bitmap .Config .ARGB_8888 )
3044 val canvas = Canvas (bmp)
3145 if (values.size < 2 ) return bmp
3246
33- val minV = values.min()
34- val maxV = values.max()
35- val span = (maxV - minV).let { if (it <= 0.0 ) 1.0 else it }
47+ val dark = context.isNightMode
48+ val accent = if (dark) accentNight else accentDay
49+ val fillColor = (accent and 0x00FFFFFF ) or 0x33000000
50+ val dividerColor = if (dark) 0x33FFFFFF .toInt() else 0x22000000 .toInt()
51+ val labelColor = if (dark) 0x99FFFFFF .toInt() else 0x99000000 .toInt()
52+ val zeroLineColor = if (dark) 0x40FFFFFF .toInt() else 0x33000000 .toInt()
3653
37- val padTop = 18f
38- val padBottom = 30f
39- val plotW = W .toFloat()
40- val plotH = H - padTop - padBottom
54+ val axisBottom = minOf(0.0 , values.min())
55+ val axisTop = axisTop(values)
56+ val span = (axisTop - axisBottom).let { if (it <= 0.0 ) 1.0 else it }
4157
42- fun x (i : Int ) = plotW * (i.toFloat() / (values.size - 1 ))
43- fun y (v : Double ) = padTop + plotH * (1f - ((v - minV) / span).toFloat())
58+ val plotW = W - PAD_LEFT
59+ val plotH = H - PAD_TOP - PAD_BOTTOM
60+
61+ fun x (i : Int ) = PAD_LEFT + plotW * (i.toFloat() / (values.size - 1 ))
62+ fun y (v : Double ) = PAD_TOP + plotH * (1f - ((v - axisBottom) / span).toFloat())
63+
64+ // Y axis: 0 line + min/max labels (mirrors chartYAxis in Views.swift)
65+ val axisLabelPaint = Paint (Paint .ANTI_ALIAS_FLAG ).apply {
66+ color = labelColor
67+ textSize = 18f
68+ }
69+ canvas.drawLine(PAD_LEFT , y(0.0 ), W .toFloat(), y(0.0 ), Paint (Paint .ANTI_ALIAS_FLAG ).apply {
70+ color = zeroLineColor
71+ strokeWidth = 1f
72+ })
73+ canvas.drawText(axisLabel(0.0 ), 2f , y(0.0 ) + 6f , axisLabelPaint)
74+ canvas.drawText(axisLabel(axisTop), 2f , y(axisTop) + 6f , axisLabelPaint)
4475
4576 // day dividers + weekday labels (drawn first, behind the series)
4677 if (times.size == values.size) {
4778 val dividerPaint = Paint (Paint .ANTI_ALIAS_FLAG ).apply {
48- color = divider
79+ color = dividerColor
4980 strokeWidth = 1.5f
5081 }
5182 val textPaint = Paint (Paint .ANTI_ALIAS_FLAG ).apply {
@@ -61,39 +92,83 @@ object ChartRenderer {
6192 if (day != lastDay) {
6293 if (lastDay != - 1 ) {
6394 val xx = x(i)
64- canvas.drawLine(xx, padTop , xx, padTop + plotH, dividerPaint)
95+ canvas.drawLine(xx, PAD_TOP , xx, PAD_TOP + plotH, dividerPaint)
6596 canvas.drawText(weekday.format(Date (times[i])), xx + 6f , H - 8f , textPaint)
6697 }
6798 lastDay = day
6899 }
69100 }
70101 }
71102
72- // area fill under the line
73- val area = Path ().apply {
74- moveTo(x(0 ), padTop + plotH)
75- for (i in values.indices) lineTo(x(i), y(values[i]))
76- lineTo(x(values.size - 1 ), padTop + plotH)
77- close()
103+ // area fill under the line (skipped for pure STEP, like iOS's `if kind != .step`)
104+ if (kind != ChartKind .STEP ) {
105+ val area = if (kind == ChartKind .AREA ) {
106+ areaPath(::x, ::y, values, y(axisBottom))
107+ } else {
108+ stepAreaPath(::x, ::y, values, y(axisBottom))
109+ }
110+ canvas.drawPath(area, Paint (Paint .ANTI_ALIAS_FLAG ).apply {
111+ style = Paint .Style .FILL
112+ color = fillColor
113+ })
78114 }
79- canvas.drawPath(area, Paint (Paint .ANTI_ALIAS_FLAG ).apply {
80- style = Paint .Style .FILL
81- color = fill
82- })
83115
84116 // series line
85- val line = Path ().apply {
86- moveTo(x(0 ), y(values[0 ]))
87- for (i in 1 until values.size) lineTo(x(i), y(values[i]))
88- }
117+ val line = if (kind == ChartKind .AREA ) linePath(::x, ::y, values) else stepLinePath(::x, ::y, values)
89118 canvas.drawPath(line, Paint (Paint .ANTI_ALIAS_FLAG ).apply {
90119 style = Paint .Style .STROKE
91- strokeWidth = 4f
92- color = green
120+ strokeWidth = if (kind == ChartKind . AREA ) 4.4f else 4f
121+ color = accent
93122 strokeJoin = Paint .Join .ROUND
94123 strokeCap = Paint .Cap .ROUND
95124 })
96125
97126 return bmp
98127 }
128+
129+ // labels: 0 + max, ceil to next integer; fractional (<1) series ceil to 0.1
130+ // so sub-unit currencies aren't flattened. Mirrors axisTop/axisBottom in
131+ // ForecastChart (Views.swift).
132+ private fun axisTop (values : List <Double >): Double {
133+ val m = values.maxOrNull() ? : 0.0
134+ if (m <= 0.0 ) return 1.0
135+ return if (m < 1.0 ) ceil(m * 10 ) / 10 else ceil(m)
136+ }
137+
138+ private fun axisLabel (v : Double ): String =
139+ if (v == v.toLong().toDouble()) v.toLong().toString() else String .format(Locale .getDefault(), " %.1f" , v)
140+
141+ private fun linePath (x : (Int ) -> Float , y : (Double ) -> Float , values : List <Double >): Path = Path ().apply {
142+ moveTo(x(0 ), y(values[0 ]))
143+ for (i in 1 until values.size) lineTo(x(i), y(values[i]))
144+ }
145+
146+ private fun areaPath (x : (Int ) -> Float , y : (Double ) -> Float , values : List <Double >, baselineY : Float ): Path =
147+ Path ().apply {
148+ moveTo(x(0 ), baselineY)
149+ for (i in values.indices) lineTo(x(i), y(values[i]))
150+ lineTo(x(values.size - 1 ), baselineY)
151+ close()
152+ }
153+
154+ /* * Staircase: horizontal to the next x at the current value, then a vertical jump. */
155+ private fun stepLinePath (x : (Int ) -> Float , y : (Double ) -> Float , values : List <Double >): Path = Path ().apply {
156+ moveTo(x(0 ), y(values[0 ]))
157+ for (i in 1 until values.size) {
158+ lineTo(x(i), y(values[i - 1 ]))
159+ lineTo(x(i), y(values[i]))
160+ }
161+ }
162+
163+ private fun stepAreaPath (x : (Int ) -> Float , y : (Double ) -> Float , values : List <Double >, baselineY : Float ): Path =
164+ Path ().apply {
165+ moveTo(x(0 ), baselineY)
166+ lineTo(x(0 ), y(values[0 ]))
167+ for (i in 1 until values.size) {
168+ lineTo(x(i), y(values[i - 1 ]))
169+ lineTo(x(i), y(values[i]))
170+ }
171+ lineTo(x(values.size - 1 ), baselineY)
172+ close()
173+ }
99174}
0 commit comments