diff --git a/CLAUDE.md b/CLAUDE.md
index 7b4d6e7c..5302c165 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -201,6 +201,26 @@ Gradle needs JDK 17+ (Crashlytics plugin); the shell default may be JDK 11 and f
screen, which has no setting of its own. Gauges past `PERFORMANCE_MAX_GAUGES` fall back into the grid,
not nowhere; the gauge dialog only warns (toast), it does not block.
+### Gauge drawer (`renderer/gauge/`)
+* `GaugeDrawer` is shared by the Gauge, Performance, Drag Racing and Brake Boosting screens (phone + AA):
+ any dial change shows on all four. Spec: `doc/specs/gauge-drawing-improvements.md`.
+* **The dial maps values through `GaugeScale`, not `pid.min`/`pid.max`.** It extends the PID range to
+ "nice" steps; progress, labels and ticks must all use `scale.fraction()` or they drift apart.
+ Pure geometry lives in `GaugeGeometry.kt` (unit-tested); keep Canvas out of it.
+* The scale bitmap is cached, so anything that decides *what* goes into it (numbers on/off, scale,
+ zones) must be in its key. Numbers skipped for a still-`null` value were cached numberless for good.
+* End labels of a dial that ends below centre sit beside the stats row; `GaugeGeometry.statsMaxWidth`
+ keeps the row clear. Fix overlaps with draw order (numbers after ticks) before moving labels.
+* Phone dials are centred on what they draw (`GaugeGeometry.dialTopOffset` from start/sweep), not on
+ their square — a 200° arc leaves the lower circle empty.
+* Units: draw `displayUnits(pid.units)` (`renderer/Units.kt`), never `pid.units` directly. ObdMetrics'
+ PID resources spell °C as a bare `C`, and they are not ours to change (shared with exports/logs).
+* Red on the dial = the PID's `alert` thresholds. The scale bitmap cache is keyed by `GaugeScale` and
+ `GaugeRedZones` too — a cache keyed by PID id alone kept a stale scale after a PID edit.
+* Phone cards may be taller than the dial (`GaugeGeometry.cardHeight`); `borderArea` is the card, `top`
+ the dial. Captions belong to the card. `contentHeight`'s bottom pad must be excluded from the height
+ the cards share, or a filled grid scrolls by a few pixels.
+
### Connectors (`:datalogger/.../connectors`)
* One `AdapterConnection` per transport, chosen by `ConnectionManager.obtain()` on
`pref.adapter.connection.type`. Two Bluetooth transports, deliberately separate:
diff --git a/doc/specs/gauge-drawing-improvements.md b/doc/specs/gauge-drawing-improvements.md
new file mode 100644
index 00000000..38bbf06c
--- /dev/null
+++ b/doc/specs/gauge-drawing-improvements.md
@@ -0,0 +1,88 @@
+# Spec: Gauge drawing improvements
+
+2026-10-08 · branch `feat/gauge-drawing-improvements` · **Status: implemented**
+
+## Summary
+
+Readability, correctness and per-frame cost fixes for the dial gauge (`GaugeDrawer`), taken from a phone Gauge-screen screenshot.
+
+
+
+**Problem.** Scale labels were thirds of the PID range (`-40, -6, 26, 60 …`, `0.8, 2.7, 4.5 …`). The last part of every dial was painted red whether or not the PID has an alert. End-of-scale labels ran into the ticks and the card border. The min / avg / max row was unlabelled and spaced by fixed offsets. A long value ran past the dial. Four gauges used under half of a portrait screen. A value outside the PID's min..max drew the progress arc past the dial's end (or backwards).
+
+**Scope.** `:screen_renderer` only: `GaugeDrawer`, `GaugeSurfaceRenderer`, new pure `GaugeGeometry.kt`; `renderer/Units.kt` used by every drawer that draws units. `GaugeDrawer` is shared, so items 1–5 and 7–9 also change the gauges of Performance, Drag Racing and Brake Boosting (phone and AA). No preference, query or data changes.
+
+## Behaviour changes
+
+| # | Area | Before | After |
+| --- | --- | --- | --- |
+| 1 | Scale labels | 6 equal parts of `min..max`, rounded (`-40, -6, 26 …`) | "Nice" steps (1, 2, 2.5, 4, 5 × 10ⁿ), 4–7 intervals, the range extended to the nearest step: `-40, 0, 40 … 160`; gear `-2, 0, 2 … 10` |
+| 2 | Red zone | Last ~2 labels / ticks red on every dial (fixed divider indexes) | Red only over the PID's alert ranges (`alert.upperThreshold`..max, min..`alert.lowerThreshold`); none without thresholds |
+| 3 | Label placement | Drawn before the ticks, so the red ticks' glow covered the end labels | Drawn after the ticks; centred at 0.75 r as before, moved inward only if the label would reach past 0.85 r |
+| 4 | Stats row | Fixed offsets from the centre, no captions | Measured, centred, equal gaps; `▼` before min, `▲` before max. Scaled down to fit 90 % of the card and, when the dial ends below its centre, the space left of the end label |
+| 5 | Value text | Fixed size | Shrunk (never enlarged) to 70 % of the dial width with its unit; label / stats stay where they were |
+| 6 | Phone layout | Square cards | When the grid does not scroll, cards share the free height up to 1.5 × width; dial centred, module name / rate stay in the card's top corners |
+| 7 | Out-of-range value | Arc drawn past the end, or backwards below min | Clamped to the dial |
+| 8 | Arc angle | Truncated to whole degrees | Float |
+| 9 | Scale bitmap cache | Keyed by PID id, size, colour; numbers skipped while the value was still `null`, and that numberless dial stayed cached | Also keyed by scale, red zones and whether numbers are drawn; numbers skipped only for non-numeric values; the replaced bitmap is recycled |
+| 11 | Dial position (phone) | Dial square centred in the card; a 200° dial draws nothing below 40° under its centre, so the bottom third of each card was empty | The drawn part of the arc (from its start / sweep angles) is centred in the card (`GaugeGeometry.dialTopOffset`) |
+| 12 | Units | `C` as defined in the ObdMetrics PID resources | `°C` / `°F` on every surface-rendered screen (gauge, Giulia, Trip Info, Performance, status panel) via `displayUnits()`; the PID data, exports and logs keep `C` |
+| 10 | Per frame | New `RadialGradient` per gauge; colour parsed / resolved per frame | Gradient cached per PID and card rect; colours resolved once |
+
+## Settings / keys touched
+
+None.
+
+## Implementation
+
+* `GaugeGeometry.kt` (pure, unit-tested): `GaugeScale.of(min, max)` picks the step by least range extension, then interval count nearest 5; `fraction()` clamps. `GaugeRedZones` turns thresholds into scale fractions. `GaugeGeometry.labelCenterRadius`, `statsMaxWidth`, `fitScale`, `statsRow`, `cardHeight`.
+* Lesson from the first device run: pulling every side label inward by its width put the end label (40° below centre on a 200° dial) into the stats row, which the `▼`/`▲` captions had made wider. The original overlap was draw order (ticks over numbers), not radius.
+* `GaugeDrawer`: `pidScale(metric)` caches scale + zones per PID, invalidated when min/max/thresholds change. Progress, numbers and ticks all use the scale's fraction. Ticks: majors on the labels, minors halfway; outer gray majors outside zones; in each zone the glowing dense ticks plus a solid band over its outer half. `DrawerSettings.dividersCount`, `dividersStepAngle`, `dividerHighlightStart` removed (no caller set them).
+* `GaugeSurfaceRenderer` (phone only, not landscape single-column): `rowHeight = cardHeight + 2 × margin`; `borderRects` take the card height, the dial top is offset by half the slack. The free height excludes `CONTENT_BOTTOM_PADDING`, otherwise a filled grid scrolls by a few pixels.
+
+## Backward compatibility
+
+Visual only. Existing profiles keep their PIDs and ranges; the dial's range may extend slightly past a PID's min/max to the nearest step (gear `-1..10` → `-2..10`), so the progress arc's position for the same value can shift a little. Dials with no alert thresholds lose their decorative red end.
+
+## Tests
+
+`screen_renderer/src/test/.../gauge/GaugeGeometryTest.kt`:
+
+| Test | Pins |
+| --- | --- |
+| temperature scale uses round labels instead of thirds of the range | `-40..160` → `-40, 0, 40 … 160` |
+| gear scale has integer labels instead of 0,8 and 2,7 | `-1..10` → `-2 … 10` |
+| common ranges get round steps without extending the range | 0..100, 0..8000, 0..300, 0..3 |
+| scale always covers the PID range with 4 to 7 intervals | Invariant over assorted ranges |
+| no negative zero label / invalid range falls back to a single interval | Edge cases |
+| value outside the scale sticks to its ends | Item 7 (clamp) |
+| red zones come from the alert thresholds only / thresholds beyond the scale draw no zone | Item 2 |
+| labels keep their radius unless they would reach into the ticks | Item 3 |
+| stats row stays clear of the end label when the dial ends below its centre / uses the card when it ends at or above | Item 4 |
+| stats row is centred with equal gaps / wider than the card is scaled down | Item 4 |
+| value text is only ever shrunk | Item 5 |
+| cards fill the free height up to a limit, stay square when scrolling | Item 6 |
+| phone dial is centred on what it draws / upper half dial moves to the middle / full circle dial stays | Item 11 |
+| `UnitsTest`: bare temperature units get the degree sign / other units are drawn as defined | Item 12 |
+
+## Risks and verification checklist
+
+- [ ] Phone Gauge screen, portrait, 4 gauges: cards fill the height, no scrollbar, module name in the card corner.
+- [ ] Portrait, many gauges (scrolls): cards square as before.
+- [ ] Landscape, 1 and several gauges.
+- [ ] A PID with an upper alert threshold shows red from the threshold; one without shows no red.
+- [ ] Edit a PID's min/max: the dial redraws.
+- [ ] A gauge showing `--` (no value yet) still shows its scale numbers.
+- [ ] End label (e.g. `120`) does not touch the `▲` max value.
+- [ ] Performance, Drag Racing, Brake Boosting gauges (phone + AA DHU): labels readable, no overlap with ticks.
+- [ ] Phone, square (scrolling) cards: dial and stats sit in the middle of the card, the arc's end stays inside it.
+- [ ] Temperatures read `°C` on Gauge, Giulia, Trip Info, Performance and the AA status panel.
+- [ ] `▼` / `▲` render (font fallback) on the target devices.
+
+## Out of scope
+
+* `°C` in the `:app` views (Dashboard, graph marker, DTC details): they format units themselves; `displayUnits` would have to move to `:common` first.
+* Fixing `C` in the ObdMetrics PID resources: a separate repository, and the unit also feeds exports and logs.
+
+* Pre-rendering the progress glow: the arc changes every frame, so it cannot be cached; the `BlurMaskFilter` cost was not measured.
+* Captions in words (`min / avg / max`): would need strings in `:app`'s resources for both locales.
diff --git a/doc/specs/img/gauge-before.png b/doc/specs/img/gauge-before.png
new file mode 100644
index 00000000..8a1cfe8b
Binary files /dev/null and b/doc/specs/img/gauge-before.png differ
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/AbstractDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/AbstractDrawer.kt
index 7a3eb86b..ea423846 100644
--- a/screen_renderer/src/main/java/org/obd/graphs/renderer/AbstractDrawer.kt
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/AbstractDrawer.kt
@@ -318,7 +318,7 @@ internal abstract class AbstractDrawer(
marginLeft += getTextWidth(currentStatusText, statusPaint) + 4F
drawText(
canvas,
- "${it.source.format(castToInt = false)}${it.pid.units ?: ""}",
+ "${it.source.format(castToInt = false)}${displayUnits(it.pid.units) ?: ""}",
marginLeft,
top,
Color.WHITE,
@@ -343,7 +343,7 @@ internal abstract class AbstractDrawer(
marginLeft += getTextWidth(currentStatusText, statusPaint) + 4F
drawText(
canvas,
- "${it.source.format(castToInt = false)}${it.pid.units ?: ""}",
+ "${it.source.format(castToInt = false)}${displayUnits(it.pid.units) ?: ""}",
marginLeft,
top,
Color.WHITE,
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/Units.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/Units.kt
new file mode 100644
index 00000000..372e6272
--- /dev/null
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/Units.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2019-2026, Tomasz Żebrowski
+ *
+ *
Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ *
http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *
Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.obd.graphs.renderer
+
+// The PID definitions (ObdMetrics resources) spell temperatures as a bare "C"; they are shared with
+// exports and logs, so the degree sign is added only where the unit is drawn.
+private val DISPLAY_UNITS =
+ mapOf(
+ "C" to "°C",
+ "F" to "°F"
+ )
+
+/** [units] as drawn on screen. */
+internal fun displayUnits(units: String?): String? = units?.let { DISPLAY_UNITS[it.trim()] ?: it }
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeDrawer.kt
index 561b1aa9..04f70868 100644
--- a/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeDrawer.kt
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeDrawer.kt
@@ -40,7 +40,9 @@ import org.obd.graphs.renderer.AbstractDrawer
import org.obd.graphs.renderer.api.GaugeProgressBarType
import org.obd.graphs.renderer.api.ScreenSettings
import org.obd.graphs.renderer.cache.TextCache
+import org.obd.graphs.renderer.displayUnits
import org.obd.graphs.round
+import org.obd.graphs.toDouble
import org.obd.graphs.toFloat
import org.obd.graphs.toNumber
import org.obd.graphs.ui.common.COLOR_WHITE
@@ -55,33 +57,65 @@ import kotlin.math.sin
private const val MIN_TEXT_VALUE_HEIGHT = 30
private const val CACHE_SCALE = 2f
+// Share of the dial width the value (with its unit) may take before it is shrunk.
+private const val VALUE_MAX_WIDTH_RATIO = 0.7f
+
+// Share of the card width the min / avg / max row may take before it is shrunk.
+private const val STATS_MAX_WIDTH_RATIO = 0.9f
+
+// Scale labels: centred at this share of the dial radius, never reaching past the outer one.
+private const val NUMBERS_RADIUS_RATIO = 0.75f
+private const val NUMBERS_MAX_OUTER_RADIUS_RATIO = 0.85f
+private const val NUMBERS_TEXT_RATIO = 0.055f
+private const val STATS_CAPTION_RATIO = 0.6f
+private const val MIN_CAPTION = "\u25BC"
+private const val MAX_CAPTION = "\u25B2"
+
data class DrawerSettings(
val gaugeProgressWidth: Float = 1.5f,
val gaugeProgressBarType: GaugeProgressBarType = GaugeProgressBarType.LONG,
val startAngle: Float = 200f,
val sweepAngle: Float = 180f,
val scaleStep: Int = 2,
- val dividersCount: Int = 12,
- val dividersStepAngle: Float = sweepAngle / dividersCount,
val longPointerSize: Float = 1f,
val padding: Float = 10f,
val dividerWidth: Float = 1f,
val lineOffset: Float = 8f,
val valueTextSize: Float = 46f,
val labelTextSize: Float = 16f,
- val scaleNumbersTextSize: Float = 12f,
- val dividerHighlightStart: Int = 9
+ val scaleNumbersTextSize: Float = 12f
)
private data class ScaleBitmapCache(
val bitmap: Bitmap,
val width: Int,
val height: Int,
- val dividerCount: Int,
+ val scale: GaugeScale,
+ val redZones: GaugeRedZones,
val progressColor: Int,
- val scaleEnabled: Boolean
+ val numbersDrawn: Boolean
)
+// The scale and red zones of one PID, rebuilt only when its range or thresholds change.
+private class PidScale(
+ val scale: GaugeScale,
+ val redZones: GaugeRedZones
+)
+
+private class CachedGradient(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float,
+ val color: Int,
+ val shader: RadialGradient
+) {
+ fun matches(
+ rect: RectF,
+ color: Int
+ ) = rect.left == left && rect.top == top && rect.right == right && rect.bottom == bottom && color == this.color
+}
+
private class GaugeDrawingCache {
val workingRect = RectF()
val arcTopRect = RectF()
@@ -113,14 +147,27 @@ internal class GaugeDrawer(
private val textCache = TextCache()
private val drawingCache = GaugeDrawingCache()
+ private val colorGray = color(R.color.gray)
+ private val colorGrayDark = color(R.color.gray_dark)
+ private val colorGrayLight = color(R.color.gray_light)
+ private val trackShadowColor = "#0D000000".toColorInt()
+
+ private val pidScales = mutableMapOf()
+ private val gradientCache = mutableMapOf()
+
private val numbersPaint =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = color(R.color.gray)
+ color = colorGray
}
private val labelPaint =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = color(R.color.gray)
+ color = colorGray
+ }
+
+ private val statsCaptionPaint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = colorGray
}
private val histogramPaint =
@@ -158,7 +205,7 @@ internal class GaugeDrawer(
private val modulePaint =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = color(R.color.gray)
+ color = colorGray
typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
}
@@ -180,9 +227,30 @@ internal class GaugeDrawer(
super.recycle()
scaleBitmapCache.values.forEach { it.bitmap.recycle() }
scaleBitmapCache.clear()
+ pidScales.clear()
+ gradientCache.clear()
textCache.clear()
}
+ private fun pidScale(metric: Metric): PidScale {
+ val pid = metric.pid
+ val min = pid.min.toDouble()
+ val max = pid.max.toDouble()
+ val lower = pid.alert?.lowerThreshold?.toDouble()
+ val upper = pid.alert?.upperThreshold?.toDouble()
+
+ val cached = pidScales[pid.id]
+ if (cached != null &&
+ cached.scale.sourceMin == min &&
+ cached.scale.sourceMax == max &&
+ cached.redZones.lower == lower &&
+ cached.redZones.upper == upper
+ ) {
+ return cached
+ }
+ return PidScale(GaugeScale.of(min, max), GaugeRedZones(lower, upper)).also { pidScales[pid.id] = it }
+ }
+
fun drawGauge(
canvas: Canvas,
metric: Metric,
@@ -214,27 +282,33 @@ internal class GaugeDrawer(
drawingCache.workingRect.bottom + strokeWidth
)
+ // A card taller than the dial keeps its captions in its own top corners.
+ val cardTop = borderArea?.top ?: top
+
if (drawMetricRate) {
- drawMetricRate(metric, drawingCache.workingRect, fontSize, width, left, top, canvas)
+ drawMetricRate(metric, drawingCache.workingRect, fontSize, width, left, cardTop, canvas)
}
if (drawModule) {
- drawModuleName(metric, drawingCache.workingRect, fontSize, width, left, top, canvas)
+ drawModuleName(metric, drawingCache.workingRect, fontSize, width, left, cardTop, canvas)
}
if (drawBorder) {
drawBorder(canvas, width, left, top, borderArea)
}
- drawContainerBackground(canvas, width, left, top, borderArea)
+ drawContainerBackground(canvas, metric.pid.id, width, left, top, borderArea)
- drawBackground(canvas, drawingCache.workingRect, drawingCache.arcTopRect, strokeWidth, strokeWidth, metric)
+ val pidScale = pidScale(metric)
+
+ drawBackground(canvas, drawingCache.workingRect, drawingCache.arcTopRect, strokeWidth, strokeWidth, metric, pidScale.scale)
drawScale(
canvas,
drawingCache.workingRect,
drawingCache.arcTopRect,
metric,
+ pidScale,
scaleEnabled,
radius,
dynamicPadding
@@ -244,6 +318,7 @@ internal class GaugeDrawer(
canvas,
area = drawingCache.workingRect,
metric = metric,
+ statsMaxWidth = statsMaxWidth(metric, pidScale.scale, scaleEnabled, radius, borderArea),
radius = radius,
labelCenterYPadding = labelCenterYPadding,
fontSize = fontSize,
@@ -296,6 +371,7 @@ internal class GaugeDrawer(
private fun drawContainerBackground(
canvas: Canvas,
+ cacheKey: Long,
width: Float,
left: Float,
top: Float,
@@ -314,20 +390,27 @@ internal class GaugeDrawer(
drawingCache.destRectF
}
- val gradientRadius = min(destRect.width(), destRect.height()) * 0.45f
-
- drawingCache.gradientColors2[0] = gradientColor
- drawingCache.gradientColors2[1] = Color.TRANSPARENT
-
+ // A RadialGradient per gauge per frame was pure allocation churn: the card rarely moves.
+ val cached = gradientCache[cacheKey]
val gradient =
- RadialGradient(
- destRect.centerX(),
- destRect.centerY(),
- gradientRadius,
- drawingCache.gradientColors2,
- drawingCache.backgroundPositions,
- Shader.TileMode.CLAMP
- )
+ if (cached != null && cached.matches(destRect, gradientColor)) {
+ cached.shader
+ } else {
+ drawingCache.gradientColors2[0] = gradientColor
+ drawingCache.gradientColors2[1] = Color.TRANSPARENT
+
+ RadialGradient(
+ destRect.centerX(),
+ destRect.centerY(),
+ min(destRect.width(), destRect.height()) * 0.45f,
+ drawingCache.gradientColors2,
+ drawingCache.backgroundPositions,
+ Shader.TileMode.CLAMP
+ ).also {
+ gradientCache[cacheKey] =
+ CachedGradient(destRect.left, destRect.top, destRect.right, destRect.bottom, gradientColor, it)
+ }
+ }
backgroundGradientPaint.shader = gradient
val cornerRadius = 8f * context.resources.displayMetrics.density
@@ -362,14 +445,15 @@ internal class GaugeDrawer(
arcTopRect: RectF,
arcTopOffset: Float,
strokeWidth: Float,
- metric: Metric
+ metric: Metric,
+ scale: GaugeScale
) {
paint.style = Paint.Style.STROKE
- paint.color = "#0D000000".toColorInt()
+ paint.color = trackShadowColor
paint.strokeWidth = strokeWidth
canvas.drawArc(rect, drawerSettings.startAngle, drawerSettings.sweepAngle, false, paint)
- paint.color = color(R.color.gray_dark)
+ paint.color = colorGrayDark
paint.strokeWidth = 2f
canvas.drawArc(
arcTopRect,
@@ -404,13 +488,14 @@ internal class GaugeDrawer(
)
val progressBarHeight = (drawingCache.arcBottomRect.top - arcTopRect.top - 2f)
- drawProgressBar(metric, canvas, rect, progressBarHeight)
+ drawProgressBar(metric, scale, canvas, rect, progressBarHeight)
paint.strokeWidth = strokeWidth
}
private fun drawProgressBar(
metric: Metric,
+ scale: GaugeScale,
canvas: Canvas,
rect: RectF,
strokeWidth: Float
@@ -428,11 +513,10 @@ internal class GaugeDrawer(
setProgressGradient(rect)
}
- val value = metric.source.toFloat()
- val startValue = metric.pid.min.toFloat()
- val endValue = metric.pid.max.toFloat()
+ // Clamped: a value past the PID's max used to sweep beyond the dial, below min backwards.
+ val fraction = scale.fraction(metric.source.toDouble())
- if (value == startValue) {
+ if (fraction == 0f) {
canvas.drawArc(
drawingCache.progressRect,
drawerSettings.startAngle,
@@ -441,28 +525,12 @@ internal class GaugeDrawer(
progressPaint
)
} else {
- val pointAngle = abs(drawerSettings.sweepAngle).toDouble() / (endValue - startValue)
- val point = (drawerSettings.startAngle + (value - startValue) * pointAngle).toInt()
- val currentSweep =
- if (drawerSettings.gaugeProgressBarType == GaugeProgressBarType.SHORT) {
- drawerSettings.gaugeProgressWidth
- } else {
- (point - drawerSettings.startAngle)
- }
-
- val startAngle =
- if (drawerSettings.gaugeProgressBarType == GaugeProgressBarType.SHORT) {
- drawerSettings.startAngle + (point - drawerSettings.startAngle)
- } else {
- drawerSettings.startAngle
- }
-
- val progressBarWidth =
- if (drawerSettings.gaugeProgressBarType == GaugeProgressBarType.SHORT) {
- strokeWidth
- } else {
- strokeWidth / 2f
- }
+ // Not truncated to whole degrees: on a short range that made the bar step visibly.
+ val point = drawerSettings.startAngle + fraction * abs(drawerSettings.sweepAngle)
+ val isShort = drawerSettings.gaugeProgressBarType == GaugeProgressBarType.SHORT
+ val currentSweep = if (isShort) drawerSettings.gaugeProgressWidth else point - drawerSettings.startAngle
+ val startAngle = if (isShort) point else drawerSettings.startAngle
+ val progressBarWidth = if (isShort) strokeWidth else strokeWidth / 2f
glowPaint.color = settings.getColorTheme().progressColor
glowPaint.strokeWidth = progressBarWidth * 2.5f
@@ -492,6 +560,7 @@ internal class GaugeDrawer(
canvas: Canvas,
area: RectF,
metric: Metric,
+ statsMaxWidth: Float,
radius: Float,
labelCenterYPadding: Float = 0f,
fontSize: Int,
@@ -509,7 +578,7 @@ internal class GaugeDrawer(
valuePaint.getTextBounds(value, 0, value.length, drawingCache.textRect)
val pid = metric.pid
- val unitText = pid.units
+ val unitText = displayUnits(pid.units)
var unitWidth = 0f
if (unitText != null) {
@@ -519,7 +588,23 @@ internal class GaugeDrawer(
valuePaint.textSize = calculatedFontSize
}
- val unitPadding = calculatedFontSize * 0.3f
+ // Layout height is taken before shrinking, so a long value does not move the label and stats.
+ val valueLayoutHeight = drawingCache.textRect.height()
+
+ // A long value (e.g. "100.0" plus its unit) ran past the dial; shrink it to the inner width.
+ val fit =
+ GaugeGeometry.fitScale(
+ drawingCache.textRect.width() + if (unitText != null) calculatedFontSize * 0.3f + unitWidth else 0f,
+ area.width() * VALUE_MAX_WIDTH_RATIO
+ )
+ val valueFontSize = calculatedFontSize * fit
+ if (fit < 1f) {
+ valuePaint.textSize = valueFontSize
+ valuePaint.getTextBounds(value, 0, value.length, drawingCache.textRect)
+ unitWidth *= fit
+ }
+
+ val unitPadding = valueFontSize * 0.3f
var valueX = area.centerX() - (drawingCache.textRect.width() / 2f)
if (value.length >= 4 && unitText != null) {
@@ -539,7 +624,7 @@ internal class GaugeDrawer(
histogramPaint.textSize = calculatedFontSize * 0.4f
val verticalGap = calculatedFontSize * 0.2f
- val valueLineH = max(drawingCache.textRect.height(), MIN_TEXT_VALUE_HEIGHT) + settings.getGaugeScreenSettings().topOffset
+ val valueLineH = max(valueLayoutHeight, MIN_TEXT_VALUE_HEIGHT) + settings.getGaugeScreenSettings().topOffset
labelPaint.getTextBounds("Ty", 0, 2, drawingCache.labelRect)
val labelLineH = drawingCache.labelRect.height()
@@ -560,7 +645,7 @@ internal class GaugeDrawer(
}
}
- val valueHeight = max(drawingCache.textRect.height(), MIN_TEXT_VALUE_HEIGHT) + settings.getGaugeScreenSettings().topOffset
+ val valueHeight = max(valueLayoutHeight, MIN_TEXT_VALUE_HEIGHT) + settings.getGaugeScreenSettings().topOffset
val valueY = centerY - valueHeight
valuePaint.setShadowLayer(radius / 4, 0f, 0f, Color.WHITE)
@@ -569,8 +654,8 @@ internal class GaugeDrawer(
val unitY = centerY - valueHeight
if (unitText != null) {
- valuePaint.textSize = calculatedFontSize * 0.32f
- valuePaint.color = color(R.color.gray)
+ valuePaint.textSize = valueFontSize * 0.32f
+ valuePaint.color = colorGray
val unitX = valueX + drawingCache.textRect.width() + unitPadding
canvas.drawText(unitText, unitX, unitY, valuePaint)
}
@@ -601,31 +686,74 @@ internal class GaugeDrawer(
}
if (statsEnabled) {
- histogramPaint.textSize = calculatedFontSize * 0.4f
- histogramPaint.getTextBounds("0000", 0, "0000".length, drawingCache.histsRect)
- var left = area.centerX() - (drawingCache.histsRect.width() * 1.5f)
-
- val statsY = labelY + drawingCache.histsRect.height() + verticalGap
+ drawStatsRow(canvas, area, metric, calculatedFontSize, labelY, verticalGap, statsMaxWidth)
+ }
+ }
- if (pid.historgam.isMinEnabled) {
- val minStr = textCache.min.get(pid.id, metric.min) { metric.min.format(pid) }
- histogramPaint.color = minValueColorScheme(metric)
- canvas.drawText(minStr, left, statsY, histogramPaint)
- left += (drawingCache.histsRect.width() * 1.2f)
+ // min / avg / max, centred with equal gaps. Fixed offsets crowded long values to the right, and
+ // nothing said which number was which: min and max get a small ▼ / ▲ caption.
+ private fun drawStatsRow(
+ canvas: Canvas,
+ area: RectF,
+ metric: Metric,
+ calculatedFontSize: Float,
+ labelY: Float,
+ verticalGap: Float,
+ maxWidth: Float
+ ) {
+ val pid = metric.pid
+ val statsTextSize = calculatedFontSize * 0.4f
+ histogramPaint.textSize = statsTextSize
+ histogramPaint.getTextBounds("0000", 0, "0000".length, drawingCache.histsRect)
+ val statsY = labelY + drawingCache.histsRect.height() + verticalGap
+
+ val minStr = if (pid.historgam.isMinEnabled) textCache.min.get(pid.id, metric.min) { metric.min.format(pid) } else null
+ val avgStr = if (pid.historgam.isAvgEnabled) textCache.avg.get(pid.id, metric.mean) { metric.mean.format(pid) } else null
+ val maxStr = if (pid.historgam.isMaxEnabled) textCache.max.get(pid.id, metric.max) { metric.max.format(pid) } else null
+
+ val captionTextSize = statsTextSize * STATS_CAPTION_RATIO
+ statsCaptionPaint.textSize = captionTextSize
+ val captionGap = statsTextSize * 0.1f
+
+ fun width(
+ caption: String?,
+ text: String?
+ ): Float =
+ if (text == null) {
+ 0f
+ } else {
+ histogramPaint.measureText(text) + if (caption != null) statsCaptionPaint.measureText(caption) + captionGap else 0f
}
- if (pid.historgam.isAvgEnabled) {
- val avgStr = textCache.avg.get(pid.id, metric.mean) { metric.mean.format(pid) }
- histogramPaint.color = settings.getColorTheme().valueColor
- canvas.drawText(avgStr, left, statsY, histogramPaint)
- left += (drawingCache.histsRect.width() * 1.5f)
- }
+ val widths = floatArrayOf(width(MIN_CAPTION, minStr), width(null, avgStr), width(MAX_CAPTION, maxStr))
+ val present = widths.indices.filter { widths[it] > 0f }
+ if (present.isEmpty()) return
+
+ val row =
+ GaugeGeometry.statsRow(
+ present.map { widths[it] }.toFloatArray(),
+ gap = drawingCache.histsRect.width() * 0.35f,
+ centerX = area.centerX(),
+ maxWidth = maxWidth
+ )
+
+ histogramPaint.textSize = statsTextSize * row.scale
+ statsCaptionPaint.textSize = captionTextSize * row.scale
- if (pid.historgam.isMaxEnabled) {
- val maxStr = textCache.max.get(pid.id, metric.max) { metric.max.format(pid) }
- histogramPaint.color = maxValueColorScheme(metric)
- canvas.drawText(maxStr, left, statsY, histogramPaint)
+ present.forEachIndexed { slot, item ->
+ var x = row.lefts[slot]
+ val (caption, text, color) =
+ when (item) {
+ 0 -> Triple(MIN_CAPTION, minStr!!, minValueColorScheme(metric))
+ 1 -> Triple(null, avgStr!!, settings.getColorTheme().valueColor)
+ else -> Triple(MAX_CAPTION, maxStr!!, maxValueColorScheme(metric))
+ }
+ if (caption != null) {
+ canvas.drawText(caption, x, statsY, statsCaptionPaint)
+ x += statsCaptionPaint.measureText(caption) + captionGap * row.scale
}
+ histogramPaint.color = color
+ canvas.drawText(text, x, statsY, histogramPaint)
}
}
@@ -634,6 +762,7 @@ internal class GaugeDrawer(
rect: RectF,
arcTopRect: RectF,
metric: Metric,
+ pidScale: PidScale,
scaleEnabled: Boolean,
radius: Float,
bitmapPadding: Float
@@ -644,13 +773,19 @@ internal class GaugeDrawer(
val pidId = metric.pid.id
val currentCache = scaleBitmapCache[pidId]
+ // The scale and red zones are part of the key: editing a PID's range or alerts must redraw it.
+ // Numbers are skipped only for non-numeric values. Skipping them while there was no value yet
+ // cached a dial without numbers until the layout changed.
+ val numbersDrawn = scaleEnabled && (metric.source.value == null || metric.source.isNumber())
+
val isValid =
currentCache != null &&
- currentCache.scaleEnabled == scaleEnabled &&
+ currentCache.numbersDrawn == numbersDrawn &&
currentCache.progressColor == settings.getColorTheme().progressColor &&
currentCache.width == targetWidth &&
currentCache.height == targetHeight &&
- currentCache.dividerCount == drawerSettings.dividersCount
+ currentCache.scale == pidScale.scale &&
+ currentCache.redZones == pidScale.redZones
drawingCache.destRectF.set(rect)
drawingCache.destRectF.inset(-bitmapPadding, -bitmapPadding)
@@ -671,68 +806,104 @@ internal class GaugeDrawer(
cacheCanvas.scale(CACHE_SCALE, CACHE_SCALE)
cacheCanvas.translate(-rect.left + bitmapPadding, -rect.top + bitmapPadding)
- if (scaleEnabled && metric.source.isNumber()) {
- drawNumbers(cacheCanvas, arcTopRect, metric, radius)
+ // Numbers last: the red ticks' glow used to paint over the end labels.
+ drawTicks(cacheCanvas, rect, pidScale)
+ if (numbersDrawn) {
+ drawNumbers(cacheCanvas, arcTopRect, pidScale, radius)
}
- drawTicks(cacheCanvas, rect)
- scaleBitmapCache[pidId] =
+ scaleBitmapCache.put(
+ pidId,
ScaleBitmapCache(
cachedBitmap,
targetWidth,
targetHeight,
- drawerSettings.dividersCount,
+ pidScale.scale,
+ pidScale.redZones,
settings.getColorTheme().progressColor,
- scaleEnabled
+ numbersDrawn
)
+ )?.bitmap?.recycle()
canvas.drawBitmap(cachedBitmap, null, drawingCache.destRectF, bitmapPaint)
}
}
+ private fun statsMaxWidth(
+ metric: Metric,
+ scale: GaugeScale,
+ scaleEnabled: Boolean,
+ radius: Float,
+ borderArea: RectF?
+ ): Float {
+ val cardLimit = (borderArea?.width() ?: drawingCache.workingRect.width()) * STATS_MAX_WIDTH_RATIO
+ if (!scaleEnabled || (metric.source.value != null && !metric.source.isNumber())) return cardLimit
+
+ val endLabel = scale.label(scale.intervals)
+ numbersPaint.textSize = drawingCache.arcTopRect.width() * NUMBERS_TEXT_RATIO
+ numbersPaint.getTextBounds(endLabel, 0, endLabel.length, drawingCache.numberTextRect)
+ val endWidth = drawingCache.numberTextRect.width().toFloat()
+ val endAngle = Math.toRadians(angleOf(1f).toDouble())
+ val endRadius =
+ GaugeGeometry.labelCenterRadius(
+ radius * NUMBERS_RADIUS_RATIO,
+ radius * NUMBERS_MAX_OUTER_RADIUS_RATIO,
+ endAngle,
+ endWidth,
+ drawingCache.numberTextRect.height().toFloat()
+ )
+ return GaugeGeometry.statsMaxWidth(cardLimit, endAngle, endRadius, endWidth, gap = radius * 0.04f)
+ }
+
+ private fun angleOf(fraction: Float): Float = drawerSettings.startAngle + fraction * drawerSettings.sweepAngle
+
private fun drawNumbers(
canvas: Canvas,
area: RectF,
- metric: Metric,
+ pidScale: PidScale,
radius: Float
) {
- val pid = metric.pid
- val startValue = pid.min.toDouble()
- val endValue = pid.max.toDouble()
- val numberOfItems = (drawerSettings.dividersCount / drawerSettings.scaleStep)
- val stepValue = (endValue - startValue) / numberOfItems
+ val scale = pidScale.scale
- val baseRadius = radius * 0.75f
+ numbersPaint.textSize = area.width() * NUMBERS_TEXT_RATIO
- val start = 0
- val end = drawerSettings.dividersCount + 1
-
- numbersPaint.textSize = area.width() * 0.055f
-
- for (j in start..end step drawerSettings.scaleStep) {
- val angle = (drawerSettings.startAngle + j * drawerSettings.dividersStepAngle) * (Math.PI / 180)
- val text = valueAsString(metric, value = (startValue + stepValue * j / drawerSettings.scaleStep).round(1))
+ for (i in 0..scale.intervals) {
+ val angle = angleOf(i.toFloat() / scale.intervals) * (Math.PI / 180)
+ val text = scale.label(i)
numbersPaint.getTextBounds(text, 0, text.length, drawingCache.numberTextRect)
-
- val x = area.left + (area.width() / 2.0f + cos(angle) * baseRadius - drawingCache.numberTextRect.width() / 2).toFloat()
- val y = area.top + (area.height() / 2.0f + sin(angle) * baseRadius + drawingCache.numberTextRect.height() / 2).toFloat()
+ val textWidth = drawingCache.numberTextRect.width().toFloat()
+ val textHeight = drawingCache.numberTextRect.height().toFloat()
+
+ val labelRadius =
+ GaugeGeometry.labelCenterRadius(
+ radius * NUMBERS_RADIUS_RATIO,
+ radius * NUMBERS_MAX_OUTER_RADIUS_RATIO,
+ angle,
+ textWidth,
+ textHeight
+ )
+ val x = area.left + (area.width() / 2.0f + cos(angle) * labelRadius - textWidth / 2).toFloat()
+ val y = area.top + (area.height() / 2.0f + sin(angle) * labelRadius + textHeight / 2).toFloat()
numbersPaint.color =
- if (j == (numberOfItems - 1) * drawerSettings.scaleStep || j == numberOfItems * drawerSettings.scaleStep) {
- settings.getColorTheme().progressColor
- } else {
- color(R.color.gray)
- }
+ if (pidScale.redZones.contains(scale.value(i))) settings.getColorTheme().progressColor else colorGray
canvas.drawText(text, x, y, numbersPaint)
}
}
+ // Major ticks sit on the labels, minor ones halfway between. Red marks the PID's alert ranges
+ // only; it used to be painted on the last part of every dial, alert or not.
private fun drawTicks(
canvas: Canvas,
- rect: RectF
+ rect: RectF,
+ pidScale: PidScale
) {
+ val scale = pidScale.scale
+ val zones = pidScale.redZones.ranges(scale)
+ val progressColor = settings.getColorTheme().progressColor
+
drawingCache.scaleRect.set(
rect.left + drawerSettings.lineOffset,
rect.top + drawerSettings.lineOffset,
@@ -740,77 +911,46 @@ internal class GaugeDrawer(
rect.bottom - drawerSettings.lineOffset
)
- val start = 0
- val end = drawerSettings.dividersCount + 1
-
- drawArcTicks(canvas, drawingCache.scaleRect, start, end, paintColor = {
- if (it == 10 || it == 12) {
- settings.getColorTheme().progressColor
- } else {
- color(R.color.gray_light)
- }
- }) {
- drawerSettings.startAngle + it * drawerSettings.dividersStepAngle
- }
-
- drawArcTicks(canvas, drawingCache.scaleRect, start, drawerSettings.dividersCount + 2) {
- drawerSettings.startAngle + it * drawerSettings.dividersStepAngle * 0.5f
+ val ticks = scale.intervals * 2
+ for (k in 0..ticks) {
+ val fraction = k.toFloat() / ticks
+ val major = k % 2 == 0
+ paint.color = if (major && zones.any { fraction in it }) progressColor else colorGrayLight
+ canvas.drawArc(drawingCache.scaleRect, angleOf(fraction), drawerSettings.dividerWidth, false, paint)
}
drawingCache.alignedOuterRect.set(rect)
drawingCache.alignedOuterRect.inset(2f, 2f)
- val grayEndIndex = drawerSettings.dividerHighlightStart
- drawArcTicks(
- canvas,
- drawingCache.alignedOuterRect,
- start,
- grayEndIndex,
- paintColor = { getScaleColor(it) }
- ) {
- drawerSettings.startAngle + it * drawerSettings.dividersStepAngle
- }
-
- val highlightStartDegrees = (drawerSettings.dividersStepAngle * drawerSettings.dividerHighlightStart + 3).toInt()
- val highlightEndDegrees = (drawerSettings.dividersStepAngle * (drawerSettings.dividersCount - 1)).toInt()
-
- drawLineTicks(
- canvas,
- drawingCache.alignedOuterRect,
- highlightStartDegrees,
- highlightEndDegrees,
- widthInDegrees = drawerSettings.dividerWidth,
- paintColor = { settings.getColorTheme().progressColor }
- ) {
- drawerSettings.startAngle + it
+ for (i in 0..scale.intervals) {
+ val fraction = i.toFloat() / scale.intervals
+ if (zones.none { fraction in it }) {
+ paint.color = colorGrayLight
+ canvas.drawArc(drawingCache.alignedOuterRect, angleOf(fraction), drawerSettings.dividerWidth, false, paint)
+ }
}
- val widthArc =
- (drawerSettings.startAngle + drawerSettings.dividersCount * (drawerSettings.dividersStepAngle - 1)) -
- (drawerSettings.startAngle + drawerSettings.dividersCount * (drawerSettings.dividersStepAngle - 3))
-
- paint.color = settings.getColorTheme().progressColor
- canvas.drawArc(
- drawingCache.alignedOuterRect,
- drawerSettings.startAngle + drawerSettings.dividersCount * (drawerSettings.dividersStepAngle - 2),
- widthArc,
- false,
- paint
- )
- }
+ paint.color = progressColor
+ zones.forEach { zone ->
+ val startDegrees = (zone.start * drawerSettings.sweepAngle).toInt()
+ val endDegrees = (zone.endInclusive * drawerSettings.sweepAngle).toInt()
+
+ drawLineTicks(
+ canvas,
+ drawingCache.alignedOuterRect,
+ startDegrees,
+ endDegrees,
+ widthInDegrees = drawerSettings.dividerWidth,
+ paintColor = { progressColor }
+ ) {
+ drawerSettings.startAngle + it
+ }
- private inline fun drawArcTicks(
- canvas: Canvas,
- rect: RectF,
- start: Int,
- end: Int,
- width: Float = drawerSettings.dividerWidth,
- paintColor: (j: Int) -> Int = { color(R.color.gray_light) },
- angle: (j: Int) -> Float
- ) {
- for (j in start..end step drawerSettings.scaleStep) {
- paint.color = paintColor(j)
- canvas.drawArc(rect, angle(j), width, false, paint)
+ // A solid band on the outer half of the zone, towards the dial's end.
+ val half = (zone.endInclusive - zone.start) / 2f
+ val bandStart = if (zone.endInclusive >= 1f) zone.start + half else zone.start
+ paint.color = progressColor
+ canvas.drawArc(drawingCache.alignedOuterRect, angleOf(bandStart), half * drawerSettings.sweepAngle, false, paint)
}
}
@@ -845,7 +985,7 @@ internal class GaugeDrawer(
val centerAngle = startAngle + (widthInDegrees / 2f)
canvas.rotate(centerAngle)
- if (color != color(R.color.gray_light)) {
+ if (color != colorGrayLight) {
glowPaint.color = color
glowPaint.strokeWidth = paint.strokeWidth * 2.0f
canvas.drawLine(radius, -dashLength / 2f, radius, dashLength / 2f, glowPaint)
@@ -907,25 +1047,6 @@ internal class GaugeDrawer(
padding: Float
): Float = (width - 2 * padding) / 2
- private inline fun getScaleColor(j: Int): Int =
- if (j == drawerSettings.dividerHighlightStart || j == drawerSettings.dividersCount) {
- settings.getColorTheme().progressColor
- } else {
- color(R.color.gray_light)
- }
-
- private inline fun valueAsString(
- metric: Metric,
- value: Double
- ): String =
- if (metric.source.command.pid.max
- .toInt() > 20
- ) {
- value.toInt().toString()
- } else {
- value.toString()
- }
-
private fun getHeightPixels(): Int = context.resources.displayMetrics.heightPixels
private fun getWidthPixels(): Int = context.resources.displayMetrics.widthPixels
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeGeometry.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeGeometry.kt
new file mode 100644
index 00000000..c3c1d584
--- /dev/null
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeGeometry.kt
@@ -0,0 +1,269 @@
+/*
+ * Copyright 2019-2026, Tomasz Żebrowski
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ *
http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *
Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.obd.graphs.renderer.gauge
+
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.ceil
+import kotlin.math.cos
+import kotlin.math.floor
+import kotlin.math.log10
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.pow
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+private val NICE_MULTIPLIERS = doubleArrayOf(1.0, 2.0, 2.5, 4.0, 5.0)
+private const val MIN_INTERVALS = 4
+private const val MAX_INTERVALS = 7
+private const val PREFERRED_INTERVALS = 5
+private const val MAX_DECIMALS = 4
+private const val EPSILON = 1e-9
+
+// A taller card than this only adds empty space around the dial.
+internal const val MAX_CARD_HEIGHT_RATIO = 1.5f
+
+/**
+ * The dial's scale: [min]..[max] cut into [intervals] steps of [step], all "nice" numbers
+ * (1, 2, 2.5, 4 or 5 × 10ⁿ). The range covers the PID's own min..max ([sourceMin], [sourceMax]),
+ * extended to the nearest step, so every label sits on a major tick.
+ */
+internal data class GaugeScale(
+ val sourceMin: Double,
+ val sourceMax: Double,
+ val min: Double,
+ val max: Double,
+ val step: Double,
+ val intervals: Int,
+ val decimals: Int
+) {
+ fun value(index: Int): Double = (min + step * index).let { if (abs(it) < step * EPSILON) 0.0 else it }
+
+ fun label(index: Int): String = String.format(Locale.ROOT, "%.${decimals}f", value(index))
+
+ /** Position of [value] on the dial, 0..1. Values outside the scale stick to its ends. */
+ fun fraction(value: Double): Float =
+ if (value.isNaN()) 0f else ((value - min) / (max - min)).coerceIn(0.0, 1.0).toFloat()
+
+ companion object {
+ fun of(
+ sourceMin: Double,
+ sourceMax: Double
+ ): GaugeScale {
+ if (!sourceMin.isFinite() || !sourceMax.isFinite() || sourceMax <= sourceMin) {
+ val min = if (sourceMin.isFinite()) sourceMin else 0.0
+ return GaugeScale(sourceMin, sourceMax, min, min + 1.0, 1.0, 1, decimals(1.0, min))
+ }
+
+ val range = sourceMax - sourceMin
+ val baseMagnitude = 10.0.pow(floor(log10(range / MAX_INTERVALS)))
+
+ var best: GaugeScale? = null
+ var bestScore = Double.MAX_VALUE
+ for (magnitude in doubleArrayOf(baseMagnitude, baseMagnitude * 10)) {
+ for (multiplier in NICE_MULTIPLIERS) {
+ val step = multiplier * magnitude
+ val min = floor(sourceMin / step + EPSILON) * step
+ val max = ceil(sourceMax / step - EPSILON) * step
+ val intervals = ((max - min) / step).roundToInt()
+ if (intervals !in MIN_INTERVALS..MAX_INTERVALS) continue
+
+ // Least extension first: it costs dial resolution. Then the label count.
+ val extension = (max - min - range) / range
+ val score = extension * 1000 + abs(intervals - PREFERRED_INTERVALS)
+ if (score < bestScore - EPSILON) {
+ bestScore = score
+ best = GaugeScale(sourceMin, sourceMax, min, max, step, intervals, decimals(step, min))
+ }
+ }
+ }
+
+ return best ?: run {
+ val step = range / PREFERRED_INTERVALS
+ GaugeScale(sourceMin, sourceMax, sourceMin, sourceMax, step, PREFERRED_INTERVALS, decimals(step, sourceMin))
+ }
+ }
+
+ private fun decimals(
+ step: Double,
+ min: Double
+ ): Int =
+ (0..MAX_DECIMALS).firstOrNull { d ->
+ val factor = 10.0.pow(d)
+ isWhole(step * factor) && isWhole(min * factor)
+ } ?: MAX_DECIMALS
+
+ private fun isWhole(value: Double) = abs(value - value.roundToInt()) < 1e-6
+ }
+}
+
+/**
+ * The red parts of the dial, as fractions of the scale: from the PID's upper alert threshold to
+ * the end, and from the start to its lower one. None when the PID defines no thresholds.
+ */
+internal data class GaugeRedZones(
+ val lower: Double?,
+ val upper: Double?
+) {
+ fun contains(value: Double): Boolean = (upper != null && value >= upper) || (lower != null && value <= lower)
+
+ fun ranges(scale: GaugeScale): List> =
+ listOfNotNull(
+ lower?.takeIf { it > scale.min }?.let { 0f..scale.fraction(it) },
+ upper?.takeIf { it < scale.max }?.let { scale.fraction(it)..1f }
+ )
+
+ fun containsFraction(
+ scale: GaugeScale,
+ fraction: Float
+ ): Boolean = ranges(scale).any { fraction in it }
+
+ companion object {
+ val NONE = GaugeRedZones(null, null)
+ }
+}
+
+internal object GaugeGeometry {
+ /**
+ * Distance from the dial centre to a scale label's centre: [baseRadius], unless the label would
+ * then reach past [maxOuterRadius] (into the ticks), in which case it moves just inside it.
+ * Pulling every label inward by its width put the end labels into the value / stats area.
+ */
+ fun labelCenterRadius(
+ baseRadius: Float,
+ maxOuterRadius: Float,
+ angleRadians: Double,
+ width: Float,
+ height: Float
+ ): Float {
+ val radialHalfExtent = abs(cos(angleRadians)).toFloat() * width / 2f +
+ abs(sin(angleRadians)).toFloat() * height / 2f
+ return min(baseRadius, maxOuterRadius - radialHalfExtent)
+ }
+
+ /**
+ * Widest the min / avg / max row may be. When the dial ends below its centre, the last scale
+ * label sits beside that row, so the row must stay clear of the label's inner edge.
+ *
+ * @param endAngleRadians the dial's end angle (canvas convention: positive is below the centre).
+ * @param endLabelRadius distance from the dial centre to the end label's centre.
+ */
+ fun statsMaxWidth(
+ cardLimit: Float,
+ endAngleRadians: Double,
+ endLabelRadius: Float,
+ endLabelWidth: Float,
+ gap: Float
+ ): Float {
+ if (sin(endAngleRadians) <= 0.0) return cardLimit
+ val labelInnerX = abs(cos(endAngleRadians)).toFloat() * endLabelRadius - endLabelWidth / 2f
+ return min(cardLimit, 2f * (labelInnerX - gap)).coerceAtLeast(0f)
+ }
+
+ /** Text size factor that makes [width] fit [maxWidth]; never enlarges. */
+ fun fitScale(
+ width: Float,
+ maxWidth: Float
+ ): Float = if (width <= maxWidth || width <= 0f) 1f else maxWidth / width
+
+ /**
+ * Left edges of items laid out in a row centred on [centerX], separated by [gap]. The whole
+ * row is scaled down by [StatsRow.scale] when wider than [maxWidth].
+ */
+ fun statsRow(
+ widths: FloatArray,
+ gap: Float,
+ centerX: Float,
+ maxWidth: Float
+ ): StatsRow {
+ if (widths.isEmpty()) return StatsRow(FloatArray(0), 1f)
+
+ val total = widths.sum() + gap * (widths.size - 1)
+ val scale = fitScale(total, maxWidth)
+ val lefts = FloatArray(widths.size)
+ var x = centerX - total * scale / 2f
+ widths.forEachIndexed { i, w ->
+ lefts[i] = x
+ x += (w + gap) * scale
+ }
+ return StatsRow(lefts, scale)
+ }
+
+ /**
+ * Offset of the dial's square from the card top that centres what the dial actually draws.
+ * A 200° dial draws nothing in the lower part of its circle, so a dial centred as a square
+ * left the bottom third of every card empty.
+ */
+ fun dialTopOffset(
+ cardHeight: Float,
+ gaugeWidth: Float,
+ startAngle: Float,
+ sweepAngle: Float
+ ): Float {
+ val radius = gaugeWidth / 2f
+ val (minSin, maxSin) = arcSinRange(startAngle, sweepAngle)
+ val drawnHeight = radius * (maxSin - minSin)
+ // The drawn part starts radius * minSin below the circle's centre, i.e. this far below its square's top.
+ val drawnTop = radius + radius * minSin
+ return ((cardHeight - drawnHeight) / 2f - drawnTop).coerceIn(0f, max(0f, cardHeight - gaugeWidth / 2f - radius * maxSin))
+ }
+
+ // Lowest and highest sine over the arc (canvas angles: 90 is the bottom, 270 the top).
+ private fun arcSinRange(
+ startAngle: Float,
+ sweepAngle: Float
+ ): Pair {
+ val start = Math.toRadians(startAngle.toDouble())
+ val end = Math.toRadians((startAngle + sweepAngle).toDouble())
+ var minSin = min(sin(start), sin(end)).toFloat()
+ var maxSin = max(sin(start), sin(end)).toFloat()
+ val from = min(startAngle, startAngle + sweepAngle)
+ val to = max(startAngle, startAngle + sweepAngle)
+ var a = ceil((from - 90f) / 360f) * 360f + 90f
+ while (a <= to) {
+ maxSin = 1f
+ a += 360f
+ }
+ a = ceil((from - 270f) / 360f) * 360f + 270f
+ while (a <= to) {
+ minSin = -1f
+ a += 360f
+ }
+ return minSin to maxSin
+ }
+
+ /**
+ * Height of a phone gauge card. Square when the grid scrolls anyway; otherwise the rows share
+ * [availableHeight], up to [MAX_CARD_HEIGHT_RATIO] × the gauge width.
+ */
+ fun cardHeight(
+ gaugeWidth: Float,
+ itemMargin: Float,
+ rows: Int,
+ availableHeight: Float
+ ): Float {
+ if (rows <= 0) return gaugeWidth
+ val shared = availableHeight / rows - 2 * itemMargin
+ return shared.coerceIn(gaugeWidth, gaugeWidth * MAX_CARD_HEIGHT_RATIO)
+ }
+}
+
+internal class StatsRow(
+ val lefts: FloatArray,
+ val scale: Float
+)
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeSurfaceRenderer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeSurfaceRenderer.kt
index 708cdd99..0ee16626 100644
--- a/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeSurfaceRenderer.kt
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/gauge/GaugeSurfaceRenderer.kt
@@ -63,6 +63,12 @@ private class GaugeLayoutCache {
private const val TAG = "cache"
+// Below the last row; the free height the cards share excludes it, or a filled grid would scroll.
+private const val CONTENT_BOTTOM_PADDING = 20f
+
+private const val MOBILE_START_ANGLE = 200f
+private const val MOBILE_SWEEP_ANGLE = 200f
+
internal class GaugeSurfaceRenderer(
context: Context,
private val settings: ScreenSettings,
@@ -84,8 +90,8 @@ internal class GaugeSurfaceRenderer(
context = context,
drawerSettings =
DrawerSettings(
- startAngle = 200f,
- sweepAngle = 200f,
+ startAngle = MOBILE_START_ANGLE,
+ sweepAngle = MOBILE_SWEEP_ANGLE,
gaugeProgressBarType = settings.getGaugeScreenSettings().gaugeProgressBarType
)
)
@@ -310,7 +316,26 @@ internal class GaugeSurfaceRenderer(
layoutCache.startX = startX(area, isAA, count)
val totalRows = kotlin.math.ceil(count / layoutCache.columns.toDouble()).toInt()
- layoutCache.contentHeight = topOffset + (totalRows * layoutCache.rowHeight) + 20f
+
+ // Phone rows were square, so a few gauges left most of a portrait screen empty. When the grid
+ // does not scroll, the cards share the free height; the dial stays square and is centred.
+ val fillsHeight = !isAA && !(isLandscape && layoutCache.columns == 1)
+ val cardHeight =
+ if (fillsHeight) {
+ GaugeGeometry.cardHeight(layoutCache.gaugeWidth, itemMargin, totalRows, area.bottom - topOffset - CONTENT_BOTTOM_PADDING)
+ } else {
+ layoutCache.gaugeWidth
+ }
+ if (fillsHeight) {
+ layoutCache.rowHeight = cardHeight + 2 * itemMargin
+ }
+ val dialOffset =
+ if (fillsHeight) {
+ GaugeGeometry.dialTopOffset(cardHeight, layoutCache.gaugeWidth, MOBILE_START_ANGLE, MOBILE_SWEEP_ANGLE)
+ } else {
+ 0f
+ }
+ layoutCache.contentHeight = topOffset + (totalRows * layoutCache.rowHeight) + CONTENT_BOTTOM_PADDING
layoutCache.maxScroll = max(0f, layoutCache.contentHeight - availableHeight)
for (i in 0 until count) {
@@ -334,12 +359,12 @@ internal class GaugeSurfaceRenderer(
}
layoutCache.centeredLefts[i] = centeredLeft
- layoutCache.centeredTops[i] = centeredTop
+ layoutCache.centeredTops[i] = centeredTop + dialOffset
layoutCache.borderRects[i].set(
centeredLeft,
centeredTop,
centeredLeft + layoutCache.gaugeWidth,
- centeredTop + layoutCache.gaugeWidth
+ centeredTop + cardHeight
)
}
}
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/giulia/GiuliaDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/giulia/GiuliaDrawer.kt
index 94f98cc2..8a39a9f4 100644
--- a/screen_renderer/src/main/java/org/obd/graphs/renderer/giulia/GiuliaDrawer.kt
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/giulia/GiuliaDrawer.kt
@@ -32,6 +32,7 @@ import org.obd.graphs.mapRange
import org.obd.graphs.renderer.AbstractDrawer
import org.obd.graphs.renderer.api.ScreenSettings
import org.obd.graphs.renderer.cache.TextCache
+import org.obd.graphs.renderer.displayUnits
import org.obd.graphs.toFloat
import org.obd.graphs.toNumber
import kotlin.math.max
@@ -362,7 +363,7 @@ internal class GiuliaDrawer(
castToInt: Boolean = false
): Float {
valuePaint.color = valueColorScheme(metric)
- val units = metric.source.command.pid.units ?: ""
+ val units = displayUnits(metric.source.command.pid.units) ?: ""
val left1 = left - getTextWidth(units, valuePaint)
valuePaint.setShadowLayer(30f * density, 0f, 0f, Color.WHITE)
@@ -375,7 +376,7 @@ internal class GiuliaDrawer(
canvas.drawText(value, left1, top, valuePaint)
- metric.source.command.pid.units?.let {
+ displayUnits(metric.source.command.pid.units)?.let {
valuePaint.color = Color.LTGRAY
valuePaint.textAlign = Paint.Align.LEFT
valuePaint.textSize = (textSize * 0.4).toFloat()
diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt
index 052f8f16..5cd71ff9 100644
--- a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt
+++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt
@@ -29,6 +29,7 @@ import org.obd.graphs.renderer.AbstractDrawer
import org.obd.graphs.renderer.MARGIN_END
import org.obd.graphs.renderer.api.ScreenSettings
import org.obd.graphs.renderer.cache.TextCache
+import org.obd.graphs.renderer.displayUnits
import org.obd.graphs.renderer.giulia.GiuliaDrawer
import org.obd.graphs.toNumber
@@ -321,7 +322,7 @@ internal class TripInfoDrawer(
var textWidth = getTextWidth(text, valuePaint) + textPadding
if (unitEnabled) {
- metric.source.command.pid.units?.let {
+ displayUnits(metric.source.command.pid.units)?.let {
valuePaint.color = Color.LTGRAY
valuePaint.textSize = (textSize * 0.4).toFloat()
canvas.drawText(it, (left + textWidth), top, valuePaint)
diff --git a/screen_renderer/src/test/java/org/obd/graphs/renderer/UnitsTest.kt b/screen_renderer/src/test/java/org/obd/graphs/renderer/UnitsTest.kt
new file mode 100644
index 00000000..f562b49d
--- /dev/null
+++ b/screen_renderer/src/test/java/org/obd/graphs/renderer/UnitsTest.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2019-2026, Tomasz Żebrowski
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ *
http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *
Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.obd.graphs.renderer
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class UnitsTest {
+ @Test
+ fun `bare temperature units get the degree sign`() {
+ assertEquals("°C", displayUnits("C"))
+ assertEquals("°F", displayUnits("F"))
+ assertEquals("°C", displayUnits(" C "))
+ }
+
+ @Test
+ fun `other units are drawn as defined`() {
+ assertEquals("%", displayUnits("%"))
+ assertEquals("°C", displayUnits("°C"))
+ assertEquals("Ctrl", displayUnits("Ctrl"))
+ assertNull(displayUnits(null))
+ }
+}
diff --git a/screen_renderer/src/test/java/org/obd/graphs/renderer/gauge/GaugeGeometryTest.kt b/screen_renderer/src/test/java/org/obd/graphs/renderer/gauge/GaugeGeometryTest.kt
new file mode 100644
index 00000000..fd422998
--- /dev/null
+++ b/screen_renderer/src/test/java/org/obd/graphs/renderer/gauge/GaugeGeometryTest.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2019-2026, Tomasz Żebrowski
+ *
+ *
Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ *
http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *
Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.obd.graphs.renderer.gauge
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GaugeGeometryTest {
+ private fun labels(scale: GaugeScale) = (0..scale.intervals).map { scale.label(it) }
+
+ @Test
+ fun `temperature scale uses round labels instead of thirds of the range`() {
+ // Was -40, -6, 26, 60, 93, 126, 160.
+ assertEquals(listOf("-40", "0", "40", "80", "120", "160"), labels(GaugeScale.of(-40.0, 160.0)))
+ }
+
+ @Test
+ fun `gear scale has integer labels instead of 0,8 and 2,7`() {
+ // Was -1.0, 0.8, 2.7, 4.5, 6.3, 8.2, 10.0; the start is extended to the nearest step.
+ assertEquals(listOf("-2", "0", "2", "4", "6", "8", "10"), labels(GaugeScale.of(-1.0, 10.0)))
+ }
+
+ @Test
+ fun `common ranges get round steps without extending the range`() {
+ assertEquals(listOf("0", "20", "40", "60", "80", "100"), labels(GaugeScale.of(0.0, 100.0)))
+ assertEquals(listOf("0", "2000", "4000", "6000", "8000"), labels(GaugeScale.of(0.0, 8000.0)))
+ assertEquals(listOf("0", "50", "100", "150", "200", "250", "300"), labels(GaugeScale.of(0.0, 300.0)))
+ assertEquals(listOf("0.0", "0.5", "1.0", "1.5", "2.0", "2.5", "3.0"), labels(GaugeScale.of(0.0, 3.0)))
+ }
+
+ @Test
+ fun `scale always covers the PID range with 4 to 7 intervals`() {
+ val ranges = listOf(-40.0 to 160.0, -1.0 to 10.0, 0.0 to 255.0, 0.0 to 65535.0, -0.5 to 0.5, 12.0 to 15.0, 0.0 to 1.0)
+ ranges.forEach { (min, max) ->
+ val scale = GaugeScale.of(min, max)
+ assertTrue("$min..$max -> $scale", scale.min <= min && scale.max >= max)
+ assertTrue("$min..$max -> $scale", scale.intervals in 4..7)
+ }
+ }
+
+ @Test
+ fun `no negative zero label`() {
+ assertEquals("0", GaugeScale.of(-40.0, 160.0).label(1))
+ }
+
+ @Test
+ fun `invalid range falls back to a single interval`() {
+ val scale = GaugeScale.of(5.0, 5.0)
+
+ assertEquals(1, scale.intervals)
+ assertEquals(0f, scale.fraction(5.0))
+ }
+
+ @Test
+ fun `value outside the scale sticks to its ends`() {
+ // The progress arc used to be drawn past the end of the dial, or backwards below min.
+ val scale = GaugeScale.of(0.0, 100.0)
+
+ assertEquals(1f, scale.fraction(250.0))
+ assertEquals(0f, scale.fraction(-30.0))
+ assertEquals(0.5f, scale.fraction(50.0))
+ assertEquals(0f, scale.fraction(Double.NaN))
+ }
+
+ @Test
+ fun `red zones come from the alert thresholds only`() {
+ val scale = GaugeScale.of(0.0, 100.0)
+
+ assertTrue(GaugeRedZones.NONE.ranges(scale).isEmpty())
+ assertEquals(listOf(0.8f..1f), GaugeRedZones(null, 80.0).ranges(scale))
+ assertEquals(listOf(0f..0.2f, 0.9f..1f), GaugeRedZones(20.0, 90.0).ranges(scale))
+ assertTrue(GaugeRedZones(null, 80.0).contains(80.0))
+ assertFalse(GaugeRedZones(null, 80.0).contains(79.9))
+ }
+
+ @Test
+ fun `thresholds beyond the scale draw no zone`() {
+ assertTrue(GaugeRedZones(-10.0, 200.0).ranges(GaugeScale.of(0.0, 100.0)).isEmpty())
+ }
+
+ @Test
+ fun `labels keep their radius unless they would reach into the ticks`() {
+ val narrowAtSide = GaugeGeometry.labelCenterRadius(75f, 85f, 0.0, 10f, 8f)
+ val wideAtSide = GaugeGeometry.labelCenterRadius(75f, 85f, 0.0, 40f, 8f)
+
+ assertEquals(75f, narrowAtSide, 0.001f)
+ assertEquals(65f, wideAtSide, 0.001f)
+ }
+
+ @Test
+ fun `stats row stays clear of the end label when the dial ends below its centre`() {
+ // 200 degree dial ends at 40 degrees, beside the stats row: "120" overlapped "max -29".
+ val end = Math.toRadians(40.0)
+ val limit = GaugeGeometry.statsMaxWidth(cardLimit = 300f, endAngleRadians = end, endLabelRadius = 100f, endLabelWidth = 30f, gap = 5f)
+
+ assertEquals(2f * (100f * kotlin.math.cos(end).toFloat() - 15f - 5f), limit, 0.01f)
+ }
+
+ @Test
+ fun `stats row uses the card when the dial ends at or above its centre`() {
+ assertEquals(300f, GaugeGeometry.statsMaxWidth(300f, Math.toRadians(0.0), 100f, 30f, 5f))
+ assertEquals(300f, GaugeGeometry.statsMaxWidth(300f, Math.toRadians(-20.0), 100f, 30f, 5f))
+ }
+
+ @Test
+ fun `stats row is centred with equal gaps`() {
+ val row = GaugeGeometry.statsRow(floatArrayOf(20f, 40f, 20f), gap = 10f, centerX = 100f, maxWidth = 500f)
+
+ assertEquals(1f, row.scale)
+ assertEquals(listOf(50f, 80f, 130f), row.lefts.toList())
+ }
+
+ @Test
+ fun `stats row wider than the card is scaled down to fit`() {
+ val row = GaugeGeometry.statsRow(floatArrayOf(100f, 100f), gap = 0f, centerX = 100f, maxWidth = 100f)
+
+ assertEquals(0.5f, row.scale)
+ assertEquals(listOf(50f, 100f), row.lefts.toList())
+ }
+
+ @Test
+ fun `value text is only ever shrunk`() {
+ assertEquals(1f, GaugeGeometry.fitScale(50f, 100f))
+ assertEquals(0.5f, GaugeGeometry.fitScale(200f, 100f))
+ }
+
+ @Test
+ fun `phone dial is centred on what it draws, not on its square`() {
+ // 200 degree dial from 200: top of the circle to 40 degrees below the centre.
+ val drawn = 50f * (1f + kotlin.math.sin(Math.toRadians(40.0)).toFloat())
+ val offset = GaugeGeometry.dialTopOffset(cardHeight = 100f, gaugeWidth = 100f, startAngle = 200f, sweepAngle = 200f)
+
+ assertEquals((100f - drawn) / 2f, offset, 0.01f)
+ }
+
+ @Test
+ fun `upper half dial moves to the middle of a square card`() {
+ assertEquals(25f, GaugeGeometry.dialTopOffset(100f, 100f, startAngle = 180f, sweepAngle = 180f), 0.01f)
+ }
+
+ @Test
+ fun `full circle dial stays where it was`() {
+ assertEquals(0f, GaugeGeometry.dialTopOffset(100f, 100f, startAngle = 0f, sweepAngle = 360f), 0.01f)
+ assertEquals(25f, GaugeGeometry.dialTopOffset(150f, 100f, startAngle = 0f, sweepAngle = 360f), 0.01f)
+ }
+
+ @Test
+ fun `cards fill the free height up to a limit, stay square when scrolling`() {
+ // Four gauges in two columns used under half of a portrait phone screen.
+ assertEquals(180f, GaugeGeometry.cardHeight(gaugeWidth = 140f, itemMargin = 6f, rows = 2, availableHeight = 384f))
+ assertEquals(210f, GaugeGeometry.cardHeight(gaugeWidth = 140f, itemMargin = 6f, rows = 2, availableHeight = 1000f))
+ assertEquals(140f, GaugeGeometry.cardHeight(gaugeWidth = 140f, itemMargin = 6f, rows = 5, availableHeight = 500f))
+ }
+}