Skip to content

Commit b71df2f

Browse files
authored
Stats: Add dual Y-axes and end-of-curve labels to the charge chart (#19)
The charge curve now reserves a right gutter for end-of-curve value labels: each series' last non-null sample is drawn in the series colour, collision-resolved against its neighbours and joined to the curve end by a faint dashed leader. Curves stop short of the right edge instead of running into it. On the session-detail chart the level series is bound to a real left Y-axis (nice-number percent ticks plus horizontal gridlines) and power to a sparse right Y-axis in watts; temperature stays self-normalised and is labelled "Temperature (shape only)" so a third axis is never implied. The dashboard's compact live card drops both axes and the elapsed-time labels - the card header already carries the time - and keeps only the end labels. LineChart gained per-side shared axes: every series assigned to a side scales against one AxisScale computed over their union, so a tick label can never describe a curve it does not belong to. Axis scale quality and label density are separate knobs (tickTarget drives the step, maxLabels only thins rendered labels), gutters are measured from the actual text, and a degradation ladder keeps the plot at least 96dp wide by dropping right-axis labels first and end labels second. The canvas and the x-label row are pinned to LTR and all chart text is measured LTR, so the time axis and BiDi-sensitive strings cannot mirror in RTL locales; the legend stays direction-aware. Canvas-drawn text is invisible to TalkBack, so the chart carries a content description naming each series' end value. The layout maths lives in a new pure ChartMath: niceScale resolves a {1,2,5}x10^k axis that covers the data while honouring optional hard bounds and a minimum step (so a full battery cannot produce a 101% tick, power cannot go negative, and two labels cannot format identically), and resolveEndLabels places the labels without overlap, dropping the lowest-priority ones when they cannot all fit. Both are unit-tested on the JVM, including the awkward cases: bounds that are not step-aligned, constant series, Float-precision containment, and label pile-ups. Chart fixtures in the debug source set render the degenerate matrix (colliding labels, constant 100%, absent and trailing-null power, 320dp, 2x font scale, RTL, compact) as engineering screenshots. They render into their own reference directory, and generate_screenshots.sh is scoped to the Play Store composables so the store flow is unaffected.
1 parent be4f0fe commit b71df2f

9 files changed

Lines changed: 1392 additions & 71 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Chart screenshot content. These composables render the dual-axis LineChart from crafted fixtures so
2+
// the screenshotTest source set can capture them to PNGs on the JVM (no device). They live in the debug
3+
// source set so they never ship in a release build, and each has an IDE @Preview for quick iteration.
4+
// They render into their own ChartScreenshotsKt/ reference dir and share nothing with the Play Store flow.
5+
package eu.darken.amply.screenshots
6+
7+
import android.content.res.Configuration
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.width
10+
import androidx.compose.material3.MaterialTheme
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.runtime.CompositionLocalProvider
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.platform.LocalDensity
15+
import androidx.compose.ui.platform.LocalLayoutDirection
16+
import androidx.compose.ui.tooling.preview.Preview
17+
import androidx.compose.ui.unit.Density
18+
import androidx.compose.ui.unit.LayoutDirection
19+
import androidx.compose.ui.unit.dp
20+
import eu.darken.amply.common.compose.PreviewWrapper
21+
import eu.darken.amply.common.compose.chart.ChartAxis
22+
import eu.darken.amply.common.compose.chart.ChartPoint
23+
import eu.darken.amply.common.compose.chart.ChartSeries
24+
import eu.darken.amply.common.compose.chart.LineChart
25+
import eu.darken.amply.common.compose.chart.YAxisSide
26+
import kotlin.math.roundToInt
27+
28+
// -- Content composables (one per screenshot) --------------------------------------------------
29+
30+
// Three monotonically rising series so all three end labels cluster near the top and must be
31+
// collision-separated.
32+
@Composable
33+
internal fun ChartCollidingEndsContent() = PreviewWrapper {
34+
val n = 9
35+
DualAxisChart(
36+
percent = ramp(n, 60f, 92f),
37+
power = ramp(n, 4_000f, 12_000f),
38+
temp = ramp(n, 300f, 360f),
39+
)
40+
}
41+
42+
// Constant 100% level: the left axis must not produce a tick above 100.
43+
@Composable
44+
internal fun ChartConstant100Content() = PreviewWrapper {
45+
val n = 9
46+
DualAxisChart(
47+
percent = List(n) { 100f },
48+
power = ramp(n, 12_000f, 3_000f),
49+
temp = ramp(n, 300f, 340f),
50+
)
51+
}
52+
53+
// Power series entirely absent: no right axis / no power end label, the rest unaffected.
54+
@Composable
55+
internal fun ChartPowerAllNullContent() = PreviewWrapper {
56+
val n = 9
57+
DualAxisChart(
58+
percent = ramp(n, 40f, 90f),
59+
power = List(n) { null },
60+
temp = ramp(n, 300f, 340f),
61+
)
62+
}
63+
64+
// Power drops out for the last samples: its leader must anchor to the true last point, not the plot edge.
65+
@Composable
66+
internal fun ChartPowerTrailingNullsContent() = PreviewWrapper {
67+
val n = 9
68+
val power = ramp(n, 15_000f, 6_000f).toMutableList()
69+
power[n - 1] = null
70+
power[n - 2] = null
71+
DualAxisChart(
72+
percent = ramp(n, 40f, 90f),
73+
power = power,
74+
temp = ramp(n, 300f, 340f),
75+
)
76+
}
77+
78+
// Narrow width: the degradation ladder should keep the plot at least 96dp wide.
79+
@Composable
80+
internal fun ChartNarrowContent() = PreviewWrapper {
81+
Box(Modifier.width(320.dp)) {
82+
DefaultDualAxisChart()
83+
}
84+
}
85+
86+
// Large font scale: gutters and end labels must grow with the measured text.
87+
@Composable
88+
internal fun ChartFontScaleContent() = PreviewWrapper {
89+
val base = LocalDensity.current
90+
CompositionLocalProvider(LocalDensity provides Density(base.density, fontScale = 2f)) {
91+
DefaultDualAxisChart()
92+
}
93+
}
94+
95+
// RTL locale: the chart draws LTR (physical) and the x-label row uses absolute padding, so both stay
96+
// aligned under the plot.
97+
@Composable
98+
internal fun ChartRtlContent() = PreviewWrapper {
99+
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
100+
DefaultDualAxisChart()
101+
}
102+
}
103+
104+
// Compact, axis-less variant (dashboard live card): end labels only, no axes, no time labels.
105+
@Composable
106+
internal fun ChartCompactContent() = PreviewWrapper {
107+
val n = 9
108+
val xs = xAxis(n)
109+
LineChart(
110+
series = listOf(
111+
ChartSeries(
112+
label = "Level",
113+
color = MaterialTheme.colorScheme.primary,
114+
points = points(xs, ramp(n, 55f, 87f)),
115+
endLabel = "87%",
116+
),
117+
ChartSeries(
118+
label = "Power",
119+
color = MaterialTheme.colorScheme.tertiary,
120+
points = points(xs, ramp(n, 15_000f, 7_800f)),
121+
endLabel = "7.8 W",
122+
),
123+
),
124+
emptyLabel = "No curve data",
125+
chartHeight = 84.dp,
126+
)
127+
}
128+
129+
// -- Shared renderer + fixture helpers ---------------------------------------------------------
130+
131+
@Composable
132+
private fun DefaultDualAxisChart() = DualAxisChart(
133+
percent = ramp(9, 40f, 90f),
134+
power = ramp(9, 18_000f, 6_000f),
135+
temp = ramp(9, 300f, 320f),
136+
)
137+
138+
@Composable
139+
private fun DualAxisChart(
140+
percent: List<Float?>,
141+
power: List<Float?>,
142+
temp: List<Float?>,
143+
modifier: Modifier = Modifier,
144+
) {
145+
val n = maxOf(percent.size, power.size, temp.size)
146+
val xs = xAxis(n)
147+
LineChart(
148+
modifier = modifier,
149+
series = listOf(
150+
ChartSeries(
151+
label = "Level",
152+
color = MaterialTheme.colorScheme.primary,
153+
points = points(xs, percent),
154+
endLabel = percent.lastOrNull { it != null }?.let { "${it.roundToInt()}%" },
155+
axisSide = YAxisSide.LEFT,
156+
),
157+
ChartSeries(
158+
label = "Power",
159+
color = MaterialTheme.colorScheme.tertiary,
160+
points = points(xs, power),
161+
endLabel = power.lastOrNull { it != null }?.let { "%.1f W".format(it / 1_000f) },
162+
axisSide = YAxisSide.RIGHT,
163+
),
164+
ChartSeries(
165+
label = "Temperature (shape only)",
166+
color = MaterialTheme.colorScheme.error,
167+
points = points(xs, temp),
168+
endLabel = temp.lastOrNull { it != null }?.let { "%.1f °C".format(it / 10f) },
169+
),
170+
),
171+
emptyLabel = "No curve data",
172+
leftAxis = ChartAxis(formatter = { "${it.roundToInt()}%" }, tickTarget = 4, bounds = 0f..100f, minStep = 1f),
173+
rightAxis = ChartAxis(
174+
formatter = { "%.1f W".format(it / 1_000f) },
175+
tickTarget = 4,
176+
maxLabels = 2,
177+
bounds = 0f..250_000f,
178+
minStep = 100f,
179+
),
180+
xAxisFormatter = { "${(it / 60_000f).roundToInt()}m" },
181+
)
182+
}
183+
184+
private fun xAxis(n: Int) = (0 until n).map { it * 300_000f }
185+
186+
private fun points(xs: List<Float>, values: List<Float?>) =
187+
xs.mapIndexed { i, x -> ChartPoint(x, values.getOrNull(i)) }
188+
189+
/** Linear ramp from [from] to [to] across [n] samples (a monotonic fixture curve). */
190+
private fun ramp(n: Int, from: Float, to: Float): List<Float?> =
191+
(0 until n).map { i -> from + (to - from) * i / (n - 1) }
192+
193+
// -- IDE previews (design-time only; the screenshotTest wrappers drive the actual capture) ------
194+
195+
@Preview(name = "Colliding ends", showBackground = true)
196+
@Composable
197+
private fun PreviewChartCollidingEnds() = ChartCollidingEndsContent()
198+
199+
@Preview(name = "Constant 100%", showBackground = true)
200+
@Composable
201+
private fun PreviewChartConstant100() = ChartConstant100Content()
202+
203+
@Preview(name = "Power all null", showBackground = true)
204+
@Composable
205+
private fun PreviewChartPowerAllNull() = ChartPowerAllNullContent()
206+
207+
@Preview(name = "Power trailing nulls", showBackground = true)
208+
@Composable
209+
private fun PreviewChartPowerTrailingNulls() = ChartPowerTrailingNullsContent()
210+
211+
@Preview(name = "Narrow 320dp", showBackground = true)
212+
@Composable
213+
private fun PreviewChartNarrow() = ChartNarrowContent()
214+
215+
@Preview(name = "Font scale 2x", showBackground = true)
216+
@Composable
217+
private fun PreviewChartFontScale() = ChartFontScaleContent()
218+
219+
@Preview(name = "RTL", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
220+
@Composable
221+
private fun PreviewChartRtl() = ChartRtlContent()
222+
223+
@Preview(name = "Compact axis-less", showBackground = true)
224+
@Composable
225+
private fun PreviewChartCompact() = ChartCompactContent()

0 commit comments

Comments
 (0)