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. + +![Phone Gauge screen before](img/gauge-before.png) + +**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)) + } +}