de.gsi.chart.axes.spi.LinearAxis Maven / Gradle / Ivy
package de.gsi.chart.axes.spi;
import java.security.InvalidParameterException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.*;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import de.gsi.chart.axes.AxisTransform;
import de.gsi.chart.axes.LogAxisType;
import de.gsi.chart.axes.TickUnitSupplier;
import de.gsi.chart.axes.spi.format.DefaultTickUnitSupplier;
import de.gsi.chart.ui.css.CssPropertyFactory;
/**
* @author rstein
*/
public class LinearAxis extends AbstractAxis {
private static final CssPropertyFactory CSS = new CssPropertyFactory<>(AbstractAxisParameter.getClassCssMetaData());
private static final int DEFAULT_TICK_COUNT = 9;
private static final int TICK_MARK_GAP = 6;
private static final double NEXT_TICK_UNIT_FACTOR = 1.01;
private static final int MAX_TICK_COUNT = 20;
private static final TickUnitSupplier DEFAULT_TICK_UNIT_SUPPLIER = new DefaultTickUnitSupplier();
private static final int DEFAULT_RANGE_LENGTH = 2;
private final transient Cache cache = new Cache();
private boolean isUpdating;
private final BooleanProperty forceZeroInRange = new SimpleBooleanProperty(this, "forceZeroInRange", false) {
@Override
protected void invalidated() {
if (isAutoRanging() || isAutoGrowRanging()) {
invalidate();
requestAxisLayout();
}
}
};
private final StyleableDoubleProperty tickUnit = CSS.createDoubleProperty(this, "tickUnit", 5d, () -> {
if (!isAutoRanging() || isAutoGrowRanging()) {
invalidate();
requestAxisLayout();
}
});
private final ObjectProperty tickUnitSupplier = new SimpleObjectProperty<>(this,
"tickUnitSupplier", LinearAxis.DEFAULT_TICK_UNIT_SUPPLIER);
/**
* Creates an {@link #autoRangingProperty() auto-ranging} LinearAxis.
*/
public LinearAxis() {
this("axis label", 0.0, 0.0, 5.0);
}
/**
* Creates a {@link #autoRangingProperty() non-auto-ranging} LinearAxis with the given upper bound, lower bound and
* tick unit.
*
* @param lowerBound the {@link #minProperty() lower bound} of the axis
* @param upperBound the {@link #maxProperty() upper bound} of the axis
* @param tickUnit the tick unit, i.e. space between tick marks
*/
public LinearAxis(final double lowerBound, final double upperBound, final double tickUnit) {
this(null, lowerBound, upperBound, tickUnit);
}
/**
* Create a {@link #autoRangingProperty() non-auto-ranging} Axis with the given upper bound, lower bound and tick
* unit.
*
* @param axisLabel the axis {@link #nameProperty() label}
* @param lowerBound the {@link #minProperty() lower bound} of the axis
* @param upperBound the {@link #maxProperty() upper bound} of the axis
* @param tickUnit the tick unit, i.e. space between tick marks
*/
public LinearAxis(final String axisLabel, final double lowerBound, final double upperBound, final double tickUnit) {
super(lowerBound, upperBound);
this.setName(axisLabel);
if (lowerBound >= upperBound || lowerBound == 0 || (lowerBound == 0 && upperBound == 0)) {
setAutoRanging(true);
}
setTickUnit(tickUnit);
setMinorTickCount(LinearAxis.DEFAULT_TICK_COUNT);
super.minProperty().addListener((evt, o, n) -> cache.updateCachedAxisVariables());
super.maxProperty().addListener((evt, o, n) -> cache.updateCachedAxisVariables());
super.scaleProperty().addListener((evt, o, n) -> cache.updateCachedAxisVariables());
widthProperty().addListener((ch, o, n) -> cache.axisWidth = getWidth());
heightProperty().addListener((ch, o, n) -> cache.axisHeight = getHeight());
isUpdating = false;
}
/**
* Computes the preferred tick unit based on the upper/lower bounds and the length of the axis in screen
* coordinates.
*
* @param axisLength the length in screen coordinates
* @return the tick unit
*/
@Override
public double computePreferredTickUnit(final double axisLength) {
final double labelSize = getTickLabelFont().getSize() * 2;
final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize);
final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, LinearAxis.MAX_TICK_COUNT), 2);
final double max = maxProperty().get();
final double min = minProperty().get();
double rawTickUnit = (max - min) / numOfTickMarks;
double prevTickUnitRounded;
double tickUnitRounded = Double.MIN_VALUE;
double minRounded = min;
double maxRounded = max;
int ticksCount;
double reqLength;
do {
if (Double.isNaN(rawTickUnit)) {
throw new IllegalArgumentException("Can't calculate axis range: data contains NaN value");
}
// Here we ignore the tickUnit property, so even if the tick unit
// was specified and the auto-range is off
// we don't use it. When narrowing the range (e.g. zoom-in) - this
// is usually ok, but if one wants
// explicitly change bounds while preserving the specified tickUnit,
// this won't work. Perhaps the usage of
// tickUnit should be independent of the auto-range so we should
// introduce autoTickUnit. The other option is
// to provide custom TickUnitSupplier that always returns the same
// tick unit.
prevTickUnitRounded = tickUnitRounded;
tickUnitRounded = computeTickUnit(rawTickUnit);
if (tickUnitRounded <= prevTickUnitRounded) {
break;
}
double firstMajorTick;
if ((isAutoRanging() || isAutoGrowRanging()) && isAutoRangeRounding()) {
minRounded = Math.floor(min / tickUnitRounded) * tickUnitRounded;
maxRounded = Math.ceil(max / tickUnitRounded) * tickUnitRounded;
firstMajorTick = minRounded;
} else {
firstMajorTick = Math.ceil(min / tickUnitRounded) * tickUnitRounded;
}
ticksCount = 0;
double maxReqTickGap = 0;
double halfOfLastTickSize = 0;
for (double major = firstMajorTick; major <= maxRounded; major += tickUnitRounded, ticksCount++) {
final double tickMarkSize = measureTickMarkLength(major);
if (major == firstMajorTick) {
halfOfLastTickSize = tickMarkSize / 2;
} else {
maxReqTickGap = Math.max(maxReqTickGap,
halfOfLastTickSize + LinearAxis.TICK_MARK_GAP + tickMarkSize / 2);
}
}
reqLength = (ticksCount - 1) * maxReqTickGap;
rawTickUnit = tickUnitRounded * LinearAxis.NEXT_TICK_UNIT_FACTOR;
} while (numOfTickMarks > 2 && (reqLength > axisLength || ticksCount > LinearAxis.MAX_TICK_COUNT));
return tickUnitRounded;
}
/**
* When {@code true} zero is always included in the visible range. This only has effect if
* {@link #autoRangingProperty() auto-ranging} is on.
*
* @return forceZeroInRange property
*/
public BooleanProperty forceZeroInRangeProperty() {
return forceZeroInRange;
}
@Override
public AxisTransform getAxisTransform() {
return null;
}
@Override
public List> getCssMetaData() {
return LinearAxis.getClassCssMetaData();
}
/**
* Get the display position along this axis for a given value. If the value is not in the current range, the
* returned value will be an extrapolation of the display position. -- cached double optimised version (shaves of
* 50% on delays)
*
* @param value The data value to work out display position for
* @return display position
*/
@Override
public double getDisplayPosition(final double value) {
// default case: linear axis computation (dependent variables are being
// cached for performance reasons)
return cache.localOffset + (value - cache.localCurrentLowerBound) * cache.localScale;
}
/**
* @return the log axis Type @see LogAxisType
*/
@Override
public LogAxisType getLogAxisType() {
return LogAxisType.LINEAR_SCALE;
}
/**
* Returns tick unit value expressed in data units.
*
* @return major tick unit value
*/
@Override
public double getTickUnit() {
return tickUnitProperty().get();
}
/**
* Returns the value of the {@link #tickUnitSupplierProperty()}.
*
* @return the TickUnitSupplier
*/
public TickUnitSupplier getTickUnitSupplier() {
return tickUnitSupplierProperty().get();
}
/**
* Get the data value for the given display position on this axis. If the axis is a CategoryAxis this will be the
* nearest value. -- cached double optimised version (shaves of 50% on delays)
*
* @param displayPosition A pixel position on this axis
* @return the nearest data value to the given pixel position or null if not on axis;
*/
@Override
public double getValueForDisplay(final double displayPosition) {
return (displayPosition - cache.localOffset) / cache.localScale + cache.localCurrentLowerBound;
}
/**
* Get the display position of the zero line along this axis.
*
* @return display position or Double.NaN if zero is not in current range;
*/
@Override
public double getZeroPosition() {
return getDisplayPosition(0);
}
/**
* Returns the value of the {@link #forceZeroInRangeProperty()}.
*
* @return value of the forceZeroInRange property
*/
public boolean isForceZeroInRange() {
return forceZeroInRange.getValue();
}
/**
* Returns the value of the {@code logAxisProperty}.
*
* @return value of the logAxis property
*/
@Override
public boolean isLogAxis() {
return false;
}
/**
* Checks if the given value is plottable on this axis
*
* @param value The value to check if its on axis
* @return true if the given value is plottable on this axis
*/
@Override
public boolean isValueOnAxis(final double value) {
return value >= getMin() && value <= getMax();
}
@Override
public void requestAxisLayout() {
if (isUpdating) {
return;
}
super.requestAxisLayout();
}
/**
* Sets the value of the {@link #forceZeroInRangeProperty()}.
*
* @param value if {@code true}, zero is always included in the visible range
*/
public void setForceZeroInRange(final boolean value) {
forceZeroInRange.setValue(value);
}
/**
* Sets the value of the {@link #tickUnitProperty()}.
*
* @param unit major tick unit
*/
@Override
public void setTickUnit(final double unit) {
tickUnitProperty().set(unit);
}
/**
* Sets the value of the {@link #tickUnitSupplierProperty()}.
*
* @param supplier the tick unit supplier. If {@code null}, the default one will be used
*/
public void setTickUnitSupplier(final TickUnitSupplier supplier) {
tickUnitSupplierProperty().set(supplier);
}
/**
* The value between each major tick mark in data units. This is automatically set if we are auto-ranging.
*
* @return tickUnit property
*/
@Override
public DoubleProperty tickUnitProperty() {
return tickUnit;
}
/**
* Strategy to compute major tick unit when auto-range is on or when axis bounds change. By default initialized to
* {@link DefaultTickUnitSupplier}.
*
* See {@link TickUnitSupplier} for more information about the expected behavior of the strategy.
*
*
* @return tickUnitSupplier property
*/
public ObjectProperty tickUnitSupplierProperty() {
return tickUnitSupplier;
}
private AxisRange computeRangeImpl(final double min, final double max, final double axisLength,
final double labelSize) {
final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize);
final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, LinearAxis.MAX_TICK_COUNT), 2);
double rawTickUnit = (max - min) / numOfTickMarks;
double prevTickUnitRounded;
double tickUnitRounded = Double.MIN_VALUE;
double minRounded = min;
double maxRounded = max;
int ticksCount;
double reqLength;
do {
if (Double.isNaN(rawTickUnit)) {
throw new IllegalArgumentException("Can't calculate axis range: data contains NaN value");
}
// Here we ignore the tickUnit property, so even if the tick unit
// was specified and the auto-range is off
// we don't use it. When narrowing the range (e.g. zoom-in) - this
// is usually ok, but if one wants
// explicitly change bounds while preserving the specified tickUnit,
// this won't work. Perhaps the usage of
// tickUnit should be independent of the auto-range so we should
// introduce autoTickUnit. The other option is
// to provide custom TickUnitSupplier that always returns the same
// tick unit.
prevTickUnitRounded = tickUnitRounded;
tickUnitRounded = computeTickUnit(rawTickUnit);
if (tickUnitRounded <= prevTickUnitRounded) {
break;
}
double firstMajorTick;
if ((isAutoRanging() || isAutoGrowRanging()) && isAutoRangeRounding()) {
minRounded = Math.floor(min / tickUnitRounded) * tickUnitRounded;
maxRounded = Math.ceil(max / tickUnitRounded) * tickUnitRounded;
firstMajorTick = minRounded;
} else {
firstMajorTick = Math.ceil(min / tickUnitRounded) * tickUnitRounded;
}
ticksCount = 0;
double maxReqTickGap = 0;
double halfOfLastTickSize = 0;
for (double major = firstMajorTick; major <= maxRounded; major += tickUnitRounded, ticksCount++) {
final double tickMarkSize = measureTickMarkLength(major);
if (major == firstMajorTick) {
halfOfLastTickSize = tickMarkSize / 2;
} else {
maxReqTickGap = Math.max(maxReqTickGap,
halfOfLastTickSize + LinearAxis.TICK_MARK_GAP + tickMarkSize / 2);
}
}
reqLength = (ticksCount - 1) * maxReqTickGap;
rawTickUnit = tickUnitRounded * LinearAxis.NEXT_TICK_UNIT_FACTOR;
} while (numOfTickMarks > 2 && (reqLength > axisLength || ticksCount > LinearAxis.MAX_TICK_COUNT));
final double newScale = calculateNewScale(axisLength, minRounded, maxRounded);
return new AxisRange(minRounded, maxRounded, axisLength, newScale, tickUnitRounded);
}
private double computeTickUnit(final double rawTickUnit) {
TickUnitSupplier unitSupplier = getTickUnitSupplier();
if (unitSupplier == null) {
unitSupplier = LinearAxis.DEFAULT_TICK_UNIT_SUPPLIER;
}
final double majorUnit = unitSupplier.computeTickUnit(rawTickUnit);
if (majorUnit <= 0) {
throw new IllegalArgumentException("The " + unitSupplier.getClass().getName()
+ " computed illegal unit value [" + majorUnit + "] for argument " + rawTickUnit);
}
return majorUnit;
}
@Override
protected AxisRange autoRange(final double minValue, final double maxValue, final double length,
final double labelSize) {
final double min = minValue > 0 && isForceZeroInRange() ? 0 : minValue;
final double max = maxValue < 0 && isForceZeroInRange() ? 0 : maxValue;
final double padding = LinearAxis.getEffectiveRange(min, max) * getAutoRangePadding();
final double paddedMin = LinearAxis.clampBoundToZero(min - padding, min);
final double paddedMax = LinearAxis.clampBoundToZero(max + padding, max);
return computeRange(paddedMin, paddedMax, length, labelSize);
}
@Override
protected List calculateMajorTickValues(final double axisLength, final AxisRange range) {
if (range == null) {
throw new InvalidParameterException("range is null");
}
final List tickValues = new ArrayList<>();
if (range.getLowerBound() == range.getUpperBound() || range.getTickUnit() <= 0) {
return Collections.singletonList(range.getLowerBound());
}
final double firstTick = LinearAxis.computeFistMajorTick(range.getLowerBound(), range.getTickUnit());
for (double major = firstTick; major <= range.getUpperBound(); major += range.getTickUnit()) {
tickValues.add(major);
}
return tickValues;
}
@Override
protected List calculateMinorTickValues() {
final List minorTickMarks = new ArrayList<>();
final double lowerBound = getMin();
final double upperBound = getMax();
final double majorUnit = getTickUnit();
final double firstMajorTick = LinearAxis.computeFistMajorTick(lowerBound, majorUnit);
final double minorUnit = majorUnit / getMinorTickCount();
for (double majorTick = firstMajorTick - majorUnit; majorTick < upperBound; majorTick += majorUnit) {
final double nextMajorTick = majorTick + majorUnit;
for (double minorTick = majorTick + minorUnit; minorTick < nextMajorTick; minorTick += minorUnit) {
if (minorTick >= lowerBound && minorTick <= upperBound) {
minorTickMarks.add(minorTick);
}
}
}
return minorTickMarks;
}
@Override
protected AxisRange computeRange(final double min, final double max, final double axisLength,
final double labelSize) {
double minValue = min;
double maxValue = max;
if (maxValue - minValue == 0) {
final double padding = getAutoRangePadding() < 0 ? 0.0 : getAutoRangePadding();
final double paddedRange = LinearAxis.getEffectiveRange(minValue, maxValue) * padding;
minValue = minValue - paddedRange / 2;
maxValue = maxValue + paddedRange / 2;
}
return computeRangeImpl(minValue, maxValue, axisLength, labelSize);
}
// -------------- STYLESHEET HANDLING
// ------------------------------------------------------------------------------
public static List> getClassCssMetaData() {
return CSS.getCssMetaData();
}
/**
* If padding pushed the bound above or below zero - stick it to zero.
*
* @param paddedBound padded bounds
* @param bound computed raw bounds
* @return clamped bounds
*/
private static double clampBoundToZero(final double paddedBound, final double bound) {
if (paddedBound < 0 && bound >= 0 || paddedBound > 0 && bound <= 0) {
return 0;
}
return paddedBound;
}
private static double computeFistMajorTick(final double lowerBound, final double tickUnit) {
return Math.ceil(lowerBound / tickUnit) * tickUnit;
}
private static double getEffectiveRange(final double min, final double max) {
double effectiveRange = max - min;
if (effectiveRange == 0) {
effectiveRange = min == 0 ? LinearAxis.DEFAULT_RANGE_LENGTH : Math.abs(min);
}
return effectiveRange;
}
protected class Cache {
protected double localScale;
protected double localCurrentLowerBound;
protected double localCurrentUpperBound;
protected double localOffset;
protected boolean isVerticalAxis;
protected double axisWidth;
protected double axisHeight;
private void updateCachedAxisVariables() {
localCurrentLowerBound = LinearAxis.super.getMin();
localCurrentUpperBound = LinearAxis.super.getMax();
localScale = scaleProperty().get();
final double zero = LinearAxis.super.getDisplayPosition(0);
localOffset = zero + localCurrentLowerBound * scaleProperty().get();
if (getSide() != null) {
isVerticalAxis = getSide().isVertical();
}
}
}
}