Stats: Show end values and battery/power axes on the charge graph - #19
Merged
Conversation
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.
…-axes # Conflicts: # app/src/main/java/eu/darken/amply/stats/ui/StatsCurrentSessionCard.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The charge graph on the statistics screen is easier to read at a glance.
Technical Context
Why the axes work this way. The three series have no common unit, so a single shared scale is meaningless. Series can now be bound to a per-side axis (
leftAxis/rightAxis+ChartSeries.axisSide), and each side computes oneAxisScaleover the union of the series assigned to it — a tick label can therefore never describe a curve it does not belong to. Temperature stays deliberately unbound (self-normalised, shape only) rather than getting a third axis, which is why the legend labels it as such when axes are shown. Scale quality and label density are separate knobs:tickTargetdrives the nice-number step,maxLabelsonly thins which ticks get rendered (the right axis shows two), so a sparse axis never degrades the plotted scale.The layout maths is pure and tested.
ChartMathholdsniceScale(a{1,2,5}×10^kaxis honouring optional hard bounds and a minimum step) andresolveEndLabels(1-D collision placement). The bounds and min-step arguments exist to stop specific misreadings: a full battery must not produce a101%tick, power must not go negative, and two watt labels must not format identically at 0.1 W display precision.resolveEndLabelstreats input order as priority order and drops the lowest-priority labels entirely rather than overlapping them, which is what keeps the 84dp card legible at large font scales. Both functions are unit-tested on the JVM, including the awkward paths: bounds that are not step-aligned, constant series, Float-precision containment, and label pile-ups.Non-obvious constraints handled. All drawing is against explicit plot bounds rather than the full canvas width, because the gutters are measured from the actual text. A degradation ladder keeps the plot at least 96dp wide by dropping right-axis labels first and end labels second, so a narrow card degrades deterministically instead of collapsing. The canvas and the x-label row are pinned to
LayoutDirection.Ltrand every chart string is measured LTR — without that, RTL locales reversed the time axis against the curves and reordered BiDi-sensitive strings like32.0 °C. The gridline at the axis minimum is skipped because the existing baseline already draws it.Review guidance.
ChartMath.niceScaleis the densest part: it resolves a grid, and if clamping to a non-step-aligned bound would cut the axis below the data it retries with a finer nice step, then falls back to anchoring at the violated bound, then to a two-tick[bound, bound]scale. Coverage and bounds containment are treated as hard guarantees; the nice step andminStepare best-effort and are relaxed in that terminal fallback. Its KDoc still describes coverage in slightly absolute terms while the implementation carries a ~1e-6 relative tolerance and is subject to Float representability past 2²⁴ — a pre-existing imprecision in wording, unreachable with this app's axes (0–100 step ≥ 1; 0–250 000 step ≥ 100, all exactly Float-representable). Flagged rather than silently left.Verification. New unit tests cover both pure functions. Eight rendered fixtures in the debug source set exercise the degenerate matrix — colliding end labels, constant 100 %, absent power data, trailing-null power, 320dp width, 2× font scale, RTL, and the compact variant — and were inspected visually.
fastlane/generate_screenshots.shis scoped to the Play Store composables so these engineering renders cannot break the store's image-count check. A simulated 20 % → 85 % charge session on an API 36 emulator confirmed both surfaces end to end: the live card renders without axes or time labels, the session detail chart shows the percentage axis with gridlines, the sparse watt labels, the temperature end label without an axis, and time ticks aligned under the plot; no crashes.