mardi 19 juin 2012

Logarithmic scale strikes back in JavaFX 2.0

JavaFX provides an impressive chart API that allows to draw a wide range of charts. Unfortunately the only available scale for XY charts is a linear implementation.
As we did it two years ago for javafx 1, we defined the logarithmic axis for javafx 2.
We received some requests of developpers who need to use a logarithmic axis but thay are confronted to the understanding of how axis works in javafx 2 and the impossibility to extend the NumberAxis, since it’s final in the API. So the only way to have a logarithmic axis is to extend directly the ValueAxis abstract class. Below, we’ll see how to implement all required methods to make it works. At the end you’ll be able to have this kind of chart :
So let’s create our LogarithmicAxis class that extends ValueAxis and define two properties that will represent the log lower and upper bounds of our axis. 

import javafx.scene.chart.ValueAxis;
/**
* A logarithmic axis implementation for JavaFX 2 charts<br>
* <br>
*
* @author Kevin Senechal
*
*/
public class LogarithmicAxis extends ValueAxis<Number> {
private final DoubleProperty logUpperBound = new SimpleDoubleProperty();
private final DoubleProperty logLowerBound = new SimpleDoubleProperty();
}

Then we bind our properties with the default bounds of the value axis. But before, we should verify the given range according to the mathematic logarithmic interval definition.

import javafx.scene.chart.ValueAxis;
/**
* A logarithmic axis implementation for JavaFX 2 charts<br>
* <br>
*
* @author Kevin Senechal
*
*/
public class LogarithmicAxis extends ValueAxis<Number> {
private final DoubleProperty logUpperBound = new SimpleDoubleProperty();
private final DoubleProperty logLowerBound = new SimpleDoubleProperty();
public LogarithmicAxis() {
super(1, 100);
bindLogBoundsToDefaultBounds();
}
public LogarithmicAxis(double lowerBound, double upperBound) {
super(lowerBound, upperBound);
try {
validateBounds(lowerBound, upperBound);
bindLogBoundsToDefaultBounds();
} catch (IllegalLogarithmicRangeException e) {
e.printStackTrace();
}
}
/**
* Bind our logarithmic bounds with the super class bounds, consider the base 10 logarithmic scale.
*/
private void bindLogBoundsToDefaultBounds() {
logLowerBound.bind(new DoubleBinding() {
{
super.bind(lowerBoundProperty());
}
@Override
protected double computeValue() {
return Math.log10(lowerBoundProperty().get());
}
});
logUpperBound.bind(new DoubleBinding() {
{
super.bind(upperBoundProperty());
}
@Override
protected double computeValue() {
return Math.log10(upperBoundProperty().get());
}
});
}
/**
* Validate the bounds by throwing an exception if the values are not conform to the mathematics log interval:
* ]0,Double.MAX_VALUE]
*
* @param lowerBound
* @param upperBound
* @throws IllegalLogarithmicRangeException
*/
private void validateBounds(double lowerBound, double upperBound) throws IllegalLogarithmicRangeException {
if (lowerBound < 0 || upperBound < 0 || lowerBound > upperBound) {
throw new IllegalLogarithmicRangeException(
"The logarithmic range should be include to ]0,Double.MAX_VALUE] and the lowerBound should be less than the upperBound");
}
}
}

Now we have to implement all abstract methods of the ValueAxis class.
The first one, calculateMinorTickMarks is used to get the list of minor tick marks position that you want to display on the axis. You could find my definition below. It’s based on the number of minor tick and the logarithmic formula.

/**
* {@inheritDoc}
*/
@Override
protected List<Number> calculateMinorTickMarks() {
Number[] range = getRange();
List<Number> minorTickMarksPositions = new ArrayList<Number>();
if (range != null) {
Number upperBound = range[1];
double logUpperBound = Math.log10(upperBound.doubleValue());
int minorTickMarkCount = getMinorTickCount();
for (double i = 0; i <= logUpperBound; i += 1) {
for (double j = 0; j <= 9; j += (1. / minorTickMarkCount)) {
double value = j * Math.pow(10, i);
minorTickMarksPositions.add(value);
}
}
}
return minorTickMarksPositions;
}

Then, the calculateTickValues method is used to calculate a list of all the data values for each tick mark in range, represented by the second parameter. The formula is the same than previously but here we want to display one tick each power of 10.

/**
* {@inheritDoc}
*/
@Override
protected List<Number> calculateTickValues(double length, Object range) {
List<Number> tickPositions = new ArrayList<Number>();
if (range != null) {
Number lowerBound = ((Number[]) range)[0];
Number upperBound = ((Number[]) range)[1];
double logLowerBound = Math.log10(lowerBound.doubleValue());
double logUpperBound = Math.log10(upperBound.doubleValue());
for (double i = 0; i <= logUpperBound; i += 1) {
for (double j = 1; j <= 9; j++) {
double value = j * Math.pow(10, i);
tickPositions.add(value);
}
}
}
return tickPositions;
}

The getRange provides the current range of the axis. A basic implementation is to return an array of the lowerBound and upperBound properties defined into the ValueAxis class.

@Override
protected Object getRange() {
return new Number[] { lowerBoundProperty().get(), upperBoundProperty().get() };
}
view raw getRange hosted with ❤ by GitHub

The getTickMarkLabel is only used to convert the number value to a string that will be displayed under the tickMark. Here I choose to use a number formatter.

@Override
protected String getTickMarkLabel(Number value) {
NumberFormat formatter = NumberFormat.getInstance();
formatter.setMaximumIntegerDigits(6);
formatter.setMinimumIntegerDigits(1);
return formatter.format(value);
}

The method setRange is used to update the range when data are added into the chart. There is two possibilities, the axis is animated or not. The simplest case is to set the lower and upper bound properties directly with the new values.

/**
* {@inheritDoc}
*/
@Override
protected void setRange(Object range, boolean animate) {
if (range != null) {
Number lowerBound = ((Number[]) range)[0];
Number upperBound = ((Number[]) range)[1];
try {
validateBounds(lowerBound.doubleValue(), upperBound.doubleValue());
} catch (IllegalLogarithmicRangeException e) {
e.printStackTrace();
}
lowerBoundProperty().set(lowerBound.doubleValue());
upperBoundProperty().set(upperBound.doubleValue());
}
}

In order to have an animated axis, we should declare two timelines that will set progressively the range and use them if the animate parameter is true.

/**
* The time of animation in ms
*/
private static final double ANIMATION_TIME = 2000;
private final Timeline lowerRangeTimeline = new Timeline();
private final Timeline upperRangeTimeline = new Timeline();

/**
* {@inheritDoc}
*/
@Override
protected void setRange(Object range, boolean animate) {
if (range != null) {
Number lowerBound = ((Number[]) range)[0];
Number upperBound = ((Number[]) range)[1];
try {
validateBounds(lowerBound.doubleValue(), upperBound.doubleValue());
} catch (IllegalLogarithmicRangeException e) {
e.printStackTrace();
}
if (animate) {
try {
lowerRangeTimeline.getKeyFrames().clear();
upperRangeTimeline.getKeyFrames().clear();
lowerRangeTimeline.getKeyFrames()
.addAll(new KeyFrame(Duration.ZERO, new KeyValue(lowerBoundProperty(), lowerBoundProperty()
.get())),
new KeyFrame(new Duration(ANIMATION_TIME), new KeyValue(lowerBoundProperty(),
lowerBound.doubleValue())));
upperRangeTimeline.getKeyFrames()
.addAll(new KeyFrame(Duration.ZERO, new KeyValue(upperBoundProperty(), upperBoundProperty()
.get())),
new KeyFrame(new Duration(ANIMATION_TIME), new KeyValue(upperBoundProperty(),
upperBound.doubleValue())));
lowerRangeTimeline.play();
upperRangeTimeline.play();
} catch (Exception e) {
lowerBoundProperty().set(lowerBound.doubleValue());
upperBoundProperty().set(upperBound.doubleValue());
}
}
lowerBoundProperty().set(lowerBound.doubleValue());
upperBoundProperty().set(upperBound.doubleValue());
}
}

We are almost done but we forgot to override 3 important methods that are used to perform auto-ranging and the matching between data and the axis (and the reverse).

@Override
public Number getValueForDisplay(double displayPosition) {
double delta = logUpperBound.get() - logLowerBound.get();
if (getSide().isVertical()) {
return Math.pow(10, (((displayPosition - getHeight()) / -getHeight()) * delta) + logLowerBound.get());
} else {
return Math.pow(10, (((displayPosition / getWidth()) * delta) + logLowerBound.get()));
}
}
@Override
public double getDisplayPosition(Number value) {
double delta = logUpperBound.get() - logLowerBound.get();
double deltaV = Math.log10(value.doubleValue()) - logLowerBound.get();
if (getSide().isVertical()) {
return (1. - ((deltaV) / delta)) * getHeight();
} else {
return ((deltaV) / delta) * getWidth();
}
}
@Override
protected Object autoRange(double minValue, double maxValue, double length, double labelSize) {
Double[] range = new Double[] { minValue, maxValue };
return range;
}

Well it’s done! I hope that this article will help you to define your own logarithmic axis and don’t hesitate to give me your feedback on this implementation.
Full code is available here :

import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.chart.ValueAxis;
import javafx.util.Duration;
/**
* A logarithmic axis implementation for JavaFX 2 charts<br>
* <br>
*
* @author Kevin Senechal
*
*/
public class LogarithmicAxis extends ValueAxis<Number> {
/**
* The time of animation in ms
*/
private static final double ANIMATION_TIME = 2000;
private final Timeline lowerRangeTimeline = new Timeline();
private final Timeline upperRangeTimeline = new Timeline();
private final DoubleProperty logUpperBound = new SimpleDoubleProperty();
private final DoubleProperty logLowerBound = new SimpleDoubleProperty();
public LogarithmicAxis() {
super(1, 100);
bindLogBoundsToDefaultBounds();
}
public LogarithmicAxis(double lowerBound, double upperBound) {
super(lowerBound, upperBound);
try {
validateBounds(lowerBound, upperBound);
bindLogBoundsToDefaultBounds();
} catch (IllegalLogarithmicRangeException e) {
e.printStackTrace();
}
}
/**
* Bind our logarithmic bounds with the super class bounds, consider the base 10 logarithmic scale.
*/
private void bindLogBoundsToDefaultBounds() {
logLowerBound.bind(new DoubleBinding() {
{
super.bind(lowerBoundProperty());
}
@Override
protected double computeValue() {
return Math.log10(lowerBoundProperty().get());
}
});
logUpperBound.bind(new DoubleBinding() {
{
super.bind(upperBoundProperty());
}
@Override
protected double computeValue() {
return Math.log10(upperBoundProperty().get());
}
});
}
/**
* Validate the bounds by throwing an exception if the values are not conform to the mathematics log interval:
* ]0,Double.MAX_VALUE]
*
* @param lowerBound
* @param upperBound
* @throws IllegalLogarithmicRangeException
*/
private void validateBounds(double lowerBound, double upperBound) throws IllegalLogarithmicRangeException {
if (lowerBound < 0 || upperBound < 0 || lowerBound > upperBound) {
throw new IllegalLogarithmicRangeException(
"The logarithmic range should be include to ]0,Double.MAX_VALUE] and the lowerBound should be less than the upperBound");
}
}
/**
* {@inheritDoc}
*/
@Override
protected List<Number> calculateMinorTickMarks() {
Number[] range = getRange();
List<Number> minorTickMarksPositions = new ArrayList<Number>();
if (range != null) {
Number upperBound = range[1];
double logUpperBound = Math.log10(upperBound.doubleValue());
int minorTickMarkCount = getMinorTickCount();
for (double i = 0; i <= logUpperBound; i += 1) {
for (double j = 0; j <= 9; j += (1. / minorTickMarkCount)) {
double value = j * Math.pow(10, i);
minorTickMarksPositions.add(value);
}
}
}
return minorTickMarksPositions;
}
/**
* {@inheritDoc}
*/
@Override
protected List<Number> calculateTickValues(double length, Object range) {
List<Number> tickPositions = new ArrayList<Number>();
if (range != null) {
Number lowerBound = ((Number[]) range)[0];
Number upperBound = ((Number[]) range)[1];
double logLowerBound = Math.log10(lowerBound.doubleValue());
double logUpperBound = Math.log10(upperBound.doubleValue());
for (double i = 0; i <= logUpperBound; i += 1) {
for (double j = 1; j <= 9; j++) {
double value = j * Math.pow(10, i);
tickPositions.add(value);
}
}
}
return tickPositions;
}
@Override
protected Number[] getRange() {
return new Number[] { lowerBoundProperty().get(), upperBoundProperty().get() };
}
@Override
protected String getTickMarkLabel(Number value) {
NumberFormat formatter = NumberFormat.getInstance();
formatter.setMaximumIntegerDigits(6);
formatter.setMinimumIntegerDigits(1);
return formatter.format(value);
}
/**
* {@inheritDoc}
*/
@Override
protected void setRange(Object range, boolean animate) {
if (range != null) {
Number lowerBound = ((Number[]) range)[0];
Number upperBound = ((Number[]) range)[1];
try {
validateBounds(lowerBound.doubleValue(), upperBound.doubleValue());
} catch (IllegalLogarithmicRangeException e) {
e.printStackTrace();
}
if (animate) {
try {
lowerRangeTimeline.getKeyFrames().clear();
upperRangeTimeline.getKeyFrames().clear();
lowerRangeTimeline.getKeyFrames()
.addAll(new KeyFrame(Duration.ZERO, new KeyValue(lowerBoundProperty(), lowerBoundProperty()
.get())),
new KeyFrame(new Duration(ANIMATION_TIME), new KeyValue(lowerBoundProperty(),
lowerBound.doubleValue())));
upperRangeTimeline.getKeyFrames()
.addAll(new KeyFrame(Duration.ZERO, new KeyValue(upperBoundProperty(), upperBoundProperty()
.get())),
new KeyFrame(new Duration(ANIMATION_TIME), new KeyValue(upperBoundProperty(),
upperBound.doubleValue())));
lowerRangeTimeline.play();
upperRangeTimeline.play();
} catch (Exception e) {
lowerBoundProperty().set(lowerBound.doubleValue());
upperBoundProperty().set(upperBound.doubleValue());
}
}
lowerBoundProperty().set(lowerBound.doubleValue());
upperBoundProperty().set(upperBound.doubleValue());
}
}
@Override
public Number getValueForDisplay(double displayPosition) {
double delta = logUpperBound.get() - logLowerBound.get();
if (getSide().isVertical()) {
return Math.pow(10, (((displayPosition - getHeight()) / -getHeight()) * delta) + logLowerBound.get());
} else {
return Math.pow(10, (((displayPosition / getWidth()) * delta) + logLowerBound.get()));
}
}
@Override
public double getDisplayPosition(Number value) {
double delta = logUpperBound.get() - logLowerBound.get();
double deltaV = Math.log10(value.doubleValue()) - logLowerBound.get();
if (getSide().isVertical()) {
return (1. - ((deltaV) / delta)) * getHeight();
} else {
return ((deltaV) / delta) * getWidth();
}
}
}

/**
* Exception to be thrown when a bound value isn't supported by the logarithmic axis<br>
* <br>
*
* @author Kevin Senechal mailto: kevin.senechal@dooapp.com
*
*/
public class IllegalLogarithmicRangeException extends Exception {
/**
* @param string
*/
public IllegalLogarithmicRangeException(String message) {
super(message);
}
}

5 commentaires:

  1. This just what I needed (I tweaked it for log base 2). But I am working with data that starts smaller than 1 and works its way up. Could you please provide some guidance on how to get axis tick labels smaller than one? (e.g. a label at 1/16, 1/8, 1/2, etc) Thanks!

    RépondreSupprimer
  2. I found a (very simple) solution. Initialize i in the outer loop in calculateTickValues to a negative number (-2 or -3 works well). The negative exponent results in some nice fractional values for the first few tick marks.

    RépondreSupprimer
  3. Thank you for sharing your solution!

    RépondreSupprimer
  4. Thanks for posting this. Is there a license associated with this code or is it free even for commercial use?

    RépondreSupprimer
  5. No there is no license associated with the code of this LogarithmicAxis, you can use it for free even for commercial use.

    RépondreSupprimer