From aaf5b2ccf065088c9d26a5c0cd33372dab8fb76f Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:34:31 -0600 Subject: [PATCH 1/2] fix(rtplot): restore inverted range support in RTTank The guard added to setRange() with the dual-scale refactoring (#3760) rejected any range where low >= high. That silently dropped inverted ranges (minimum > maximum), which the Tank widget used to render with the scale running top-down and the fill growing from the top. Only non-finite and zero-width ranges are ignored now. Since LogTicks only supports ascending ranges, an inverted range is applied ascending while the scale is logarithmic, and re-applied as requested when the scale goes back to linear. Alarm limit lines follow the orientation of the range instead of being skipped for inverted ranges. A non-finite value shows an empty tank for both range directions instead of a full one when the range is inverted. RTTankTest covers the accepted and ignored ranges. --- .../org/csstudio/javafx/rtplot/RTTank.java | 72 ++++++++++++++----- .../csstudio/javafx/rtplot/RTTankTest.java | 29 ++++++-- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 8c294c7b05..2f6d8e29d1 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -97,6 +97,10 @@ public class RTTank extends Canvas /** Current value, i.e. fill level */ private volatile double value = 5.0; + /** Requested value range, see {@link #setRange}; NaN until set */ + private volatile double range_low = Double.NaN; + private volatile double range_high = Double.NaN; + /** Does layout need to be re-computed? */ protected final AtomicBoolean need_layout = new AtomicBoolean(true); @@ -273,6 +277,7 @@ public void setLogScale(final boolean logscale) { scale.setLogarithmic(logscale); right_scale.setLogarithmic(logscale); + applyRange(); requestUpdate(); } @@ -441,34 +446,65 @@ public void setPerpendicularTickLabels(final boolean perpendicular) requestUpdate(); } - /** Set value range - * @param low Lower limit - * @param high Upper limit + /** Set value range. + * + *

An inverted range ({@code low > high}) runs the scale top-down + * and fills the tank from the top. A logarithmic scale always runs + * bottom-up. Non-finite and zero-width ranges are ignored. + * + * @param low Value at the bottom of the tank + * @param high Value at the top of the tank */ public void setRange(final double low, final double high) { - // Guard against NaN, Infinite, or inverted/flat range - if (!Double.isFinite(low) || !Double.isFinite(high) || low >= high) + if (!Double.isFinite(low) || !Double.isFinite(high) || low == high) + return; + range_low = low; + range_high = high; + applyRange(); + } + + /** @return Current value range of the scale */ + public AxisRange getValueRange() + { + return scale.getValueRange(); + } + + /** Push the requested range to both scales, ascending for a log scale */ + private void applyRange() + { + double low = range_low; + double high = range_high; + if (Double.isNaN(low)) return; + if (scale.isLogarithmic() && low > high) + { + low = range_high; + high = range_low; + } scale.setValueRange(low, high); right_scale.setValueRange(low, high); } - /** @param value Set value */ + /** @param value Set value; a non-finite value shows an empty tank */ public void setValue(final double value) { if (Double.isFinite(value)) this.value = value; else - this.value = scale.getValueRange().getLow(); + { + final AxisRange range = scale.getValueRange(); + this.value = Math.min(range.getLow(), range.getHigh()); + } requestUpdate(); } - /** Map a value to a Y pixel within the plot bounds (low value at bottom). + /** Map a value to a Y pixel within the plot bounds. * Returns -1 when the mapping is undefined (e.g. log scale with non-positive inputs). + * @param normal Range runs bottom-up? Otherwise the low value is at the top */ private int valueToY(final Rectangle pb, final double min, final double max, - final double v, final boolean logscale) + final double v, final boolean logscale, final boolean normal) { final double frac; if (logscale) @@ -479,17 +515,17 @@ private int valueToY(final Rectangle pb, final double min, final double max, } else frac = (v - min) / (max - min); - return (int) (pb.y + pb.height * (1.0 - frac)); + return (int) (pb.y + pb.height * (normal ? 1.0 - frac : frac)); } /** Draw a single horizontal limit line across the tank area at the given value. */ private void drawLimitLineAt(final Graphics2D gc, final Rectangle pb, - final double min, final double max, + final double min, final double max, final boolean normal, final double limit, final Color color) { if (!Double.isFinite(limit) || limit <= min || limit >= max) return; - final int y = valueToY(pb, min, max, limit, scale.isLogarithmic()); + final int y = valueToY(pb, min, max, limit, scale.isLogarithmic(), normal); if (y < pb.y || y > pb.y + pb.height) return; gc.setColor(color); @@ -642,18 +678,18 @@ protected Image updateImageBuffer() final double lim_lo = limit_lo; final double lim_hi = limit_hi; final double lim_hihi = limit_hihi; - if (normal && (!Double.isNaN(lim_lolo) || !Double.isNaN(lim_lo) || - !Double.isNaN(lim_hi) || !Double.isNaN(lim_hihi))) + if (!Double.isNaN(lim_lolo) || !Double.isNaN(lim_lo) || + !Double.isNaN(lim_hi) || !Double.isNaN(lim_hihi)) { if (limits_from_pv) gc.setStroke(new BasicStroke(2f)); else gc.setStroke(new BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[]{6f, 4f}, 0f)); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lolo, limit_major_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lo, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hi, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hihi, limit_major_color); + drawLimitLineAt(gc, plot_bounds, min, max, normal, lim_lolo, limit_major_color); + drawLimitLineAt(gc, plot_bounds, min, max, normal, lim_lo, limit_minor_color); + drawLimitLineAt(gc, plot_bounds, min, max, normal, lim_hi, limit_minor_color); + drawLimitLineAt(gc, plot_bounds, min, max, normal, lim_hihi, limit_major_color); gc.setStroke(new BasicStroke(1f)); } diff --git a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java index c9e5177051..2ad37ffbf3 100644 --- a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java +++ b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java @@ -39,17 +39,36 @@ public void testConstruction() assertThat(tank, not(nullValue())); } - /** setRange should reject invalid ranges */ + /** setRange should ignore non-finite and zero-width ranges */ @Test - public void testSetRangeRejectsInvalid() + public void testSetRangeIgnoresInvalid() { final RTTank tank = new RTTank(); - // Should silently ignore these — no exception + tank.setRange(0, 100); tank.setRange(Double.NaN, 100); tank.setRange(0, Double.NaN); - tank.setRange(100, 100); // flat - tank.setRange(100, 0); // inverted + tank.setRange(100, 100); tank.setRange(Double.POSITIVE_INFINITY, 100); + assertThat(tank.getValueRange().getLow(), equalTo(0.0)); + assertThat(tank.getValueRange().getHigh(), equalTo(100.0)); + } + + /** An inverted range is kept, except on a log scale which is always ascending */ + @Test + public void testInvertedRange() + { + final RTTank tank = new RTTank(); + tank.setRange(100, 1); + assertThat(tank.getValueRange().getLow(), equalTo(100.0)); + assertThat(tank.getValueRange().getHigh(), equalTo(1.0)); + + tank.setLogScale(true); + assertThat(tank.getValueRange().getLow(), equalTo(1.0)); + assertThat(tank.getValueRange().getHigh(), equalTo(100.0)); + + tank.setLogScale(false); + assertThat(tank.getValueRange().getLow(), equalTo(100.0)); + assertThat(tank.getValueRange().getHigh(), equalTo(1.0)); } /** setValue should handle NaN and Infinity */ From 69bdf827bbb477a31e5036e15baeeba0e6996b09 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:34:31 -0600 Subject: [PATCH 2/2] fix(rtplot): apply the label format override on linear scales LinearTicks stores the format set through setLabelFormat() but only LogTicks applied it, so the 'format' and 'precision' properties of the Tank widget had no effect unless the scale was logarithmic. format() now applies the override, which covers every label that compute() creates in both classes and keeps the "almost zero" patch that avoids labels like "-0.00". The extra relabeling pass in LogTicks is dropped since it is no longer needed. Also strips the trailing whitespace in LinearTicks that fails the pre-commit hook. LinearTicksTest covers the override and the zero patch. --- .../javafx/rtplot/internal/LinearTicks.java | 30 +++++-------------- .../javafx/rtplot/internal/LogTicks.java | 5 ---- .../rtplot/internal/LinearTicksTest.java | 21 +++++++++++++ 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LinearTicks.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LinearTicks.java index 6703336ff7..16f50d7481 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LinearTicks.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LinearTicks.java @@ -53,9 +53,9 @@ public class LinearTicks extends Ticks /** Threshold for order-of-magnitude to use exponential notation */ private long exponential_threshold = 4; - /** User-specified override format, or {@code null} for automatic selection. - * When set, it is applied to all non-empty major tick labels after - * {@code compute()} finishes its internal layout. + /** User-specified format for the tick labels, or {@code null} for the + * automatic format. Applied by {@link #format(Double)}, so it covers + * every label that {@code compute()} creates. */ private volatile NumberFormat label_fmt_override = null; @@ -93,21 +93,6 @@ public boolean isPerpendicularTickLabels() return perpendicular_tick_labels; } - /** Re-apply {@code fmt} to every non-empty major tick label in {@code ticks}. - * @param ticks List to mutate in-place - * @param fmt Format to use - */ - protected static void relabelTicks(final List> ticks, - final NumberFormat fmt) - { - for (int i = 0; i < ticks.size(); i++) - { - final MajorTick t = ticks.get(i); - if (!t.getLabel().isEmpty()) - ticks.set(i, new MajorTick<>(t.getValue(), fmt.format(t.getValue()))); - } - } - /** @param order_of_magnitude determines when to use exponential notation */ public void setExponentialThreshold(long order_of_magnitude) { @@ -194,7 +179,7 @@ public void compute(Double low, Double high, final Graphics2D gc, final int scre double distance = selectNiceStep(min_distance); if (distance == 0.0) throw new Error("Broken tickmark computation"); - + // Update num_fmt based on distance between major tick labels. // For example, an axis with range 0 .. 10 would ordinarily use precision 0 // and axis markers like 0, 2, 4, 6, 8, 10. @@ -280,6 +265,7 @@ public void compute(Double low, Double high, final Graphics2D gc, final int scre major_ticks.add(0, new MajorTick<>(low, format(low))); major_ticks.add(new MajorTick<>(high, format(high))); } + this.major_ticks = major_ticks; this.minor_ticks = minor_ticks; } @@ -385,9 +371,9 @@ public String format(final Double num) return "Inf"; // Patch numbers that are "very close to zero" // to avoid "-0.00" or "0.0e-22" - if (Math.abs(num) < zero_threshold) - return num_fmt.format(0.0); - return num_fmt.format(num); + final double val = Math.abs(num) < zero_threshold ? 0.0 : num; + final NumberFormat override = getLabelFormatOverride(); + return (override != null) ? override.format(val) : num_fmt.format(val); } /** {@inheritDoc} */ diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LogTicks.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LogTicks.java index 3a76def321..36fae4ff4d 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LogTicks.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LogTicks.java @@ -136,11 +136,6 @@ else if (decadeExps.size() >= 2) major_ticks.add( new MajorTick<>(high, format(high))); } - // Apply user-specified label format override if set. - final NumberFormat override = getLabelFormatOverride(); - if (override != null) - relabelTicks(major_ticks, override); - this.major_ticks = major_ticks; this.minor_ticks = minor_ticks; } diff --git a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/LinearTicksTest.java b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/LinearTicksTest.java index 074cb4f8e5..25c00171f7 100644 --- a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/LinearTicksTest.java +++ b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/internal/LinearTicksTest.java @@ -9,9 +9,11 @@ import org.csstudio.javafx.rtplot.TicksTestBase; import org.csstudio.javafx.rtplot.internal.LinearTicks; +import org.csstudio.javafx.rtplot.internal.MajorTick; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; /** JUnit test @@ -47,6 +49,25 @@ public void testNiceDistance() } } + /** A label format set by the user applies to every label, and a value + * that is numerically almost zero still reads as zero, not "-0.00" */ + @Test + public void testLabelFormat() + { + final LinearTicks ticks = new LinearTicks(); + ticks.setLabelFormat(LinearTicks.createDecimalFormat(2)); + ticks.compute(-0.7, 0.7, gc, buf.getWidth()); + for (MajorTick tick : ticks.getMajorTicks()) + { + final String label = tick.getLabel(); + if (label.isEmpty()) + continue; + assertThat(label, not(equalTo("-0.00"))); + assertThat(label + " has two decimals", label.matches("-?\\d+\\.\\d\\d"), equalTo(true)); + } + assertThat(ticks.format(-1e-17), equalTo("0.00")); + } + @Test public void testNormalTicks() {