Skip to content

Instantly share code, notes, and snippets.

@sirolf2009
Created January 3, 2019 10:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sirolf2009/ae8a7897b57dcf902b4ed747b05641f9 to your computer and use it in GitHub Desktop.
Save sirolf2009/ae8a7897b57dcf902b4ed747b05641f9 to your computer and use it in GitHub Desktop.
JavaFX LineChart with gaps, use Double.NaN for gaps.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
import javafx.scene.chart.LineChart;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.PathElement;
public class GapLineChart<X,Y> extends LineChart<X, Y> {
public GapLineChart(Axis<X> xAxis, Axis<Y> yAxis) {
super(xAxis, yAxis);
}
public GapLineChart(Axis<X> xAxis, Axis<Y> yAxis, ObservableList<Series<X, Y>> data) {
super(xAxis, yAxis, data);
}
@Override
protected void layoutPlotChildren() {
List<PathElement> constructedPath = new ArrayList(getData().size());
for (int seriesIndex = 0; seriesIndex < getData().size(); seriesIndex++) {
Series<X, Y> series = getData().get(seriesIndex);
if(series.getNode() instanceof Path) {
ObservableList<PathElement> seriesLine = ((Path) series.getNode()).getElements();
seriesLine.clear();
constructedPath.clear();
for (Iterator<Data<X, Y>> it = getDisplayedDataIterator(series); it.hasNext();) {
Data<X, Y> item = it.next();
double x = getXAxis().getDisplayPosition(item.getXValue());
double y = getYAxis().getDisplayPosition(getYAxis().toRealValue(getYAxis().toNumericValue(item.getYValue())));
if(Double.isNaN(x) || Double.isNaN(y)) {
Data<X,Y> next = series.getData().get(series.getData().indexOf(item)+1);
double nextX = getXAxis().getDisplayPosition(next.getXValue());
double nextY = getYAxis().getDisplayPosition(getYAxis().toRealValue(getYAxis().toNumericValue(next.getYValue())));
constructedPath.add(new MoveTo(nextX, nextY));
} else {
constructedPath.add(new LineTo(x, y));
Node symbol = item.getNode();
if(symbol != null) {
double w = symbol.prefWidth(-1);
double h = symbol.prefHeight(-1);
symbol.resizeRelocate(x - (w / 2), y - (h / 2), w, h);
}
}
}
if(!constructedPath.isEmpty()) {
PathElement first = constructedPath.get(0);
seriesLine.add(new MoveTo(getX(first), getY(first)));
seriesLine.addAll(constructedPath);
}
}
}
}
public double getX(PathElement element) {
if(element instanceof LineTo) {
return getX((LineTo) element);
} else if(element instanceof MoveTo) {
return getX((MoveTo) element);
} else {
throw new IllegalArgumentException(element+" is not a valid type");
}
}
public double getX(LineTo element) {
return element.getX();
}
public double getX(MoveTo element) {
return element.getX();
}
public double getY(PathElement element) {
if(element instanceof LineTo) {
return getY((LineTo) element);
} else if(element instanceof MoveTo) {
return getY((MoveTo) element);
} else {
throw new IllegalArgumentException(element+" is not a valid type");
}
}
public double getY(LineTo element) {
return element.getY();
}
public double getY(MoveTo element) {
return element.getY();
}
}
import static java.util.stream.Collectors.toList;
import com.sun.javafx.css.converters.SizeConverter;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.BooleanPropertyBase;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WritableValue;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleableProperty;
import javafx.geometry.Dimension2D;
import javafx.geometry.Side;
import javafx.scene.chart.ValueAxis;
import javafx.util.StringConverter;
/**
* A axis class that plots a range of numbers with major tick marks every "tickUnit". You can use any Number type with
* this axis, Long, Double, BigDecimal etc.
*
* @since JavaFX 2.0
*/
public final class GapNumberAxis extends ValueAxis<Number> {
private final StringProperty currentFormatterProperty = new SimpleStringProperty(this, "currentFormatter", "");
private final DefaultFormatter defaultFormatter = new DefaultFormatter(this);
// -------------- PUBLIC PROPERTIES --------------------------------------------------------------------------------
/** When true zero is always included in the visible range. This only has effect if auto-ranging is on. */
private BooleanProperty forceZeroInRange = new BooleanPropertyBase(true) {
@Override
protected void invalidated() {
// This will effect layout if we are auto ranging
if(isAutoRanging()) {
requestAxisLayout();
invalidateRange();
}
}
@Override
public Object getBean() {
return GapNumberAxis.this;
}
@Override
public String getName() {
return "forceZeroInRange";
}
};
public final boolean isForceZeroInRange() {
return forceZeroInRange.getValue();
}
public final void setForceZeroInRange(boolean value) {
forceZeroInRange.setValue(value);
}
public final BooleanProperty forceZeroInRangeProperty() {
return forceZeroInRange;
}
/** The value between each major tick mark in data units. This is automatically set if we are auto-ranging. */
private DoubleProperty tickUnit = new StyleableDoubleProperty(5) {
@Override
protected void invalidated() {
if(!isAutoRanging()) {
invalidateRange();
requestAxisLayout();
}
}
@Override
public CssMetaData<GapNumberAxis, Number> getCssMetaData() {
return StyleableProperties.TICK_UNIT;
}
@Override
public Object getBean() {
return GapNumberAxis.this;
}
@Override
public String getName() {
return "tickUnit";
}
};
public final double getTickUnit() {
return tickUnit.get();
}
public final void setTickUnit(double value) {
tickUnit.set(value);
}
public final DoubleProperty tickUnitProperty() {
return tickUnit;
}
// -------------- CONSTRUCTORS -------------------------------------------------------------------------------------
/**
* Create a auto-ranging NumberAxis
*/
public GapNumberAxis() {
}
/**
* Create a non-auto-ranging NumberAxis with the given upper bound, lower bound and tick unit
*
* @param lowerBound
* The lower bound for this axis, ie min plottable value
* @param upperBound
* The upper bound for this axis, ie max plottable value
* @param tickUnit
* The tick unit, ie space between tickmarks
*/
public GapNumberAxis(double lowerBound, double upperBound, double tickUnit) {
super(lowerBound, upperBound);
setTickUnit(tickUnit);
}
/**
* Create a non-auto-ranging NumberAxis with the given upper bound, lower bound and tick unit
*
* @param axisLabel
* The name to display for this axis
* @param lowerBound
* The lower bound for this axis, ie min plottable value
* @param upperBound
* The upper bound for this axis, ie max plottable value
* @param tickUnit
* The tick unit, ie space between tickmarks
*/
public GapNumberAxis(String axisLabel, double lowerBound, double upperBound, double tickUnit) {
super(lowerBound, upperBound);
setTickUnit(tickUnit);
setLabel(axisLabel);
}
@Override
public void invalidateRange(List<Number> data) {
List<Number> realData = data.stream().filter(number -> number != null && !Double.isNaN(number.doubleValue())).collect(toList());
super.invalidateRange(realData);
}
// -------------- PROTECTED METHODS --------------------------------------------------------------------------------
/**
* Get the string label name for a tick mark with the given value
*
* @param value
* The value to format into a tick label string
* @return A formatted string for the given value
*/
@Override
protected String getTickMarkLabel(Number value) {
StringConverter<Number> formatter = getTickLabelFormatter();
if(formatter == null)
formatter = defaultFormatter;
return formatter.toString(value);
}
/**
* Called to get the current axis range.
*
* @return A range object that can be passed to setRange() and calculateTickValues()
*/
@Override
protected Object getRange() {
return new Object[] {
getLowerBound(),
getUpperBound(),
getTickUnit(),
getScale(),
currentFormatterProperty.get()
};
}
/**
* Called to set the current axis range to the given range. If isAnimating() is true then this method should
* animate the range to the new range.
*
* @param range
* A range object returned from autoRange()
* @param animate
* If true animate the change in range
*/
@Override
protected void setRange(Object range, boolean animate) {
final Object[] rangeProps = (Object[]) range;
final double lowerBound = (Double) rangeProps[0];
final double upperBound = (Double) rangeProps[1];
final double tickUnit = (Double) rangeProps[2];
final double scale = (Double) rangeProps[3];
final String formatter = (String) rangeProps[4];
currentFormatterProperty.set(formatter);
setLowerBound(lowerBound);
setUpperBound(upperBound);
setTickUnit(tickUnit);
currentLowerBound.set(lowerBound);
setScale(scale);
}
/**
* Calculate a list of all the data values for each tick mark in range
*
* @param length
* The length of the axis in display units
* @param range
* A range object returned from autoRange()
* @return A list of tick marks that fit along the axis if it was the given length
*/
@Override
protected List<Number> calculateTickValues(double length, Object range) {
final Object[] rangeProps = (Object[]) range;
final double lowerBound = (Double) rangeProps[0];
final double upperBound = (Double) rangeProps[1];
final double tickUnit = (Double) rangeProps[2];
List<Number> tickValues = new ArrayList<>();
if(lowerBound == upperBound) {
tickValues.add(lowerBound);
} else if(tickUnit <= 0) {
tickValues.add(lowerBound);
tickValues.add(upperBound);
} else if(tickUnit > 0) {
tickValues.add(lowerBound);
if(((upperBound - lowerBound) / tickUnit) > 2000) {
// This is a ridiculous amount of major tick marks, something has probably gone wrong
System.err.println("Warning we tried to create more than 2000 major tick marks on a NumberAxis. " +
"Lower Bound=" + lowerBound + ", Upper Bound=" + upperBound + ", Tick Unit=" + tickUnit);
} else {
if(lowerBound + tickUnit < upperBound) {
// If tickUnit is integer, start with the nearest integer
double major = Math.rint(tickUnit) == tickUnit ? Math.ceil(lowerBound) : lowerBound + tickUnit;
int count = (int) Math.ceil((upperBound - major) / tickUnit);
for(int i = 0; major < upperBound && i < count; major += tickUnit, i++) {
if(!tickValues.contains(major)) {
tickValues.add(major);
}
}
}
}
tickValues.add(upperBound);
}
return tickValues;
}
/**
* Calculate a list of the data values for every minor tick mark
*
* @return List of data values where to draw minor tick marks
*/
@Override
protected List<Number> calculateMinorTickMarks() {
final List<Number> minorTickMarks = new ArrayList<>();
final double lowerBound = getLowerBound();
final double upperBound = getUpperBound();
final double tickUnit = getTickUnit();
final double minorUnit = tickUnit / Math.max(1, getMinorTickCount());
if(tickUnit > 0) {
if(((upperBound - lowerBound) / minorUnit) > 10000) {
// This is a ridiculous amount of major tick marks, something has probably gone wrong
System.err.println("Warning we tried to create more than 10000 minor tick marks on a NumberAxis. " +
"Lower Bound=" + getLowerBound() + ", Upper Bound=" + getUpperBound() + ", Tick Unit=" + tickUnit);
return minorTickMarks;
}
final boolean tickUnitIsInteger = Math.rint(tickUnit) == tickUnit;
if(tickUnitIsInteger) {
double minor = Math.floor(lowerBound) + minorUnit;
int count = (int) Math.ceil((Math.ceil(lowerBound) - minor) / minorUnit);
for(int i = 0; minor < Math.ceil(lowerBound) && i < count; minor += minorUnit, i++) {
if(minor > lowerBound) {
minorTickMarks.add(minor);
}
}
}
double major = tickUnitIsInteger ? Math.ceil(lowerBound) : lowerBound;
int count = (int) Math.ceil((upperBound - major) / tickUnit);
for(int i = 0; major < upperBound && i < count; major += tickUnit, i++) {
final double next = Math.min(major + tickUnit, upperBound);
double minor = major + minorUnit;
int minorCount = (int) Math.ceil((next - minor) / minorUnit);
for(int j = 0; minor < next && j < minorCount; minor += minorUnit, j++) {
minorTickMarks.add(minor);
}
}
}
return minorTickMarks;
}
/**
* Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks
*
* @param value
* tick mark value
* @param range
* range to use during calculations
* @return size of tick mark label for given value
*/
@Override
protected Dimension2D measureTickMarkSize(Number value, Object range) {
final Object[] rangeProps = (Object[]) range;
final String formatter = (String) rangeProps[4];
return measureTickMarkSize(value, getTickLabelRotation(), formatter);
}
/**
* Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks
*
* @param value
* tick mark value
* @param rotation
* The text rotation
* @param numFormatter
* The number formatter
* @return size of tick mark label for given value
*/
private Dimension2D measureTickMarkSize(Number value, double rotation, String numFormatter) {
String labelText;
StringConverter<Number> formatter = getTickLabelFormatter();
if(formatter == null)
formatter = defaultFormatter;
if(formatter instanceof DefaultFormatter) {
labelText = ((DefaultFormatter) formatter).toString(value, numFormatter);
} else {
labelText = formatter.toString(value);
}
return measureTickMarkLabelSize(labelText, rotation);
}
/**
* Called to set the upper and lower bound and anything else that needs to be auto-ranged
*
* @param minValue
* The min data value that needs to be plotted on this axis
* @param maxValue
* The max data value that needs to be plotted on this axis
* @param length
* The length of the axis in display coordinates
* @param labelSize
* The approximate average size a label takes along the axis
* @return The calculated range
*/
@Override
protected Object autoRange(double minValue, double maxValue, double length, double labelSize) {
final Side side = getSide();
// check if we need to force zero into range
if(isForceZeroInRange()) {
if(maxValue < 0) {
maxValue = 0;
} else if(minValue > 0) {
minValue = 0;
}
}
// calculate the number of tick-marks we can fit in the given length
int numOfTickMarks = (int) Math.floor(length / labelSize);
// can never have less than 2 tick marks one for each end
numOfTickMarks = Math.max(numOfTickMarks, 2);
int minorTickCount = Math.max(getMinorTickCount(), 1);
double range = maxValue - minValue;
if(range != 0 && range / (numOfTickMarks * minorTickCount) <= Math.ulp(minValue)) {
range = 0;
}
// pad min and max by 2%, checking if the range is zero
final double paddedRange = (range == 0)
? minValue == 0 ? 2 : Math.abs(minValue) * 0.02
: Math.abs(range) * 1.02;
final double padding = (paddedRange - range) / 2;
// if min and max are not zero then add padding to them
double paddedMin = minValue - padding;
double paddedMax = maxValue + padding;
// check padding has not pushed min or max over zero line
if((paddedMin < 0 && minValue >= 0) || (paddedMin > 0 && minValue <= 0)) {
// padding pushed min above or below zero so clamp to 0
paddedMin = 0;
}
if((paddedMax < 0 && maxValue >= 0) || (paddedMax > 0 && maxValue <= 0)) {
// padding pushed min above or below zero so clamp to 0
paddedMax = 0;
}
// calculate tick unit for the number of ticks can have in the given data range
double tickUnit = paddedRange / (double) numOfTickMarks;
// search for the best tick unit that fits
double tickUnitRounded = 0;
double minRounded = 0;
double maxRounded = 0;
int count = 0;
double reqLength = Double.MAX_VALUE;
String formatter = "0.00000000";
// loop till we find a set of ticks that fit length and result in a total of less than 20 tick marks
while(reqLength > length || count > 20) {
int exp = (int) Math.floor(Math.log10(tickUnit));
final double mant = tickUnit / Math.pow(10, exp);
double ratio = mant;
if(mant > 5d) {
exp++;
ratio = 1;
} else if(mant > 1d) {
ratio = mant > 2.5 ? 5 : 2.5;
}
if(exp > 1) {
formatter = "#,##0";
} else if(exp == 1) {
formatter = "0";
} else {
final boolean ratioHasFrac = Math.rint(ratio) != ratio;
final StringBuilder formatterB = new StringBuilder("0");
int n = ratioHasFrac ? Math.abs(exp) + 1 : Math.abs(exp);
if(n > 0)
formatterB.append(".");
for(int i = 0; i < n; ++i) {
formatterB.append("0");
}
formatter = formatterB.toString();
}
tickUnitRounded = ratio * Math.pow(10, exp);
// move min and max to nearest tick mark
minRounded = Math.floor(paddedMin / tickUnitRounded) * tickUnitRounded;
maxRounded = Math.ceil(paddedMax / tickUnitRounded) * tickUnitRounded;
// calculate the required length to display the chosen tick marks for real, this will handle if there are
// huge numbers involved etc or special formatting of the tick mark label text
double maxReqTickGap = 0;
double last = 0;
count = (int) Math.ceil((maxRounded - minRounded) / tickUnitRounded);
double major = minRounded;
for(int i = 0; major <= maxRounded && i < count; major += tickUnitRounded, i++) {
Dimension2D markSize = measureTickMarkSize(major, getTickLabelRotation(), formatter);
double size = side.isVertical() ? markSize.getHeight() : markSize.getWidth();
if(i == 0) { // first
last = size / 2;
} else {
maxReqTickGap = Math.max(maxReqTickGap, last + 6 + (size / 2));
}
}
reqLength = (count - 1) * maxReqTickGap;
tickUnit = tickUnitRounded;
// fix for RT-35600 where a massive tick unit was being selected
// unnecessarily. There is probably a better solution, but this works
// well enough for now.
if(numOfTickMarks == 2 && reqLength > length) {
break;
}
if(reqLength > length || count > 20)
tickUnit *= 2; // This is just for the while loop, if there are still too many ticks
}
// calculate new scale
final double newScale = calculateNewScale(length, minRounded, maxRounded);
// return new range
return new Object[] { minRounded, maxRounded, tickUnitRounded, newScale, formatter };
}
// -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------
/** @treatAsPrivate implementation detail */
private static class StyleableProperties {
private static final CssMetaData<GapNumberAxis, Number> TICK_UNIT = new CssMetaData<GapNumberAxis, Number>("-fx-tick-unit",
SizeConverter.getInstance(), 5.0) {
@Override
public boolean isSettable(GapNumberAxis n) {
return n.tickUnit == null || !n.tickUnit.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(GapNumberAxis n) {
return (StyleableProperty<Number>) (WritableValue<Number>) n.tickUnitProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(ValueAxis.getClassCssMetaData());
styleables.add(TICK_UNIT);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
/**
* @return The CssMetaData associated with this class, which may include the
* CssMetaData of its super classes.
* @since JavaFX 8.0
*/
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
/**
* {@inheritDoc}
*
* @since JavaFX 8.0
*/
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
// -------------- INNER CLASSES ------------------------------------------------------------------------------------
/**
* Default number formatter for NumberAxis, this stays in sync with auto-ranging and formats values appropriately.
* You can wrap this formatter to add prefixes or suffixes;
*
* @since JavaFX 2.0
*/
public static class DefaultFormatter extends StringConverter<Number> {
private DecimalFormat formatter;
private String prefix = null;
private String suffix = null;
/**
* Construct a DefaultFormatter for the given NumberAxis
*
* @param axis
* The axis to format tick marks for
*/
public DefaultFormatter(final GapNumberAxis axis) {
formatter = axis.isAutoRanging() ? new DecimalFormat(axis.currentFormatterProperty.get()) : new DecimalFormat();
final ChangeListener<Object> axisListener = (observable, oldValue, newValue) -> {
formatter = axis.isAutoRanging() ? new DecimalFormat(axis.currentFormatterProperty.get()) : new DecimalFormat();
};
axis.currentFormatterProperty.addListener(axisListener);
axis.autoRangingProperty().addListener(axisListener);
}
/**
* Construct a DefaultFormatter for the given NumberAxis with a prefix and/or suffix.
*
* @param axis
* The axis to format tick marks for
* @param prefix
* The prefix to append to the start of formatted number, can be null if not needed
* @param suffix
* The suffix to append to the end of formatted number, can be null if not needed
*/
public DefaultFormatter(GapNumberAxis axis, String prefix, String suffix) {
this(axis);
this.prefix = prefix;
this.suffix = suffix;
}
/**
* Converts the object provided into its string form.
* Format of the returned string is defined by this converter.
*
* @return a string representation of the object passed in.
* @see StringConverter#toString
*/
@Override
public String toString(Number object) {
return toString(object, formatter);
}
private String toString(Number object, String numFormatter) {
if(numFormatter == null || numFormatter.isEmpty()) {
return toString(object, formatter);
} else {
return toString(object, new DecimalFormat(numFormatter));
}
}
private String toString(Number object, DecimalFormat formatter) {
if(prefix != null && suffix != null) {
return prefix + formatter.format(object) + suffix;
} else if(prefix != null) {
return prefix + formatter.format(object);
} else if(suffix != null) {
return formatter.format(object) + suffix;
} else {
return formatter.format(object);
}
}
/**
* Converts the string provided into a Number defined by the this converter.
* Format of the string and type of the resulting object is defined by this converter.
*
* @return a Number representation of the string passed in.
* @see StringConverter#toString
*/
@Override
public Number fromString(String string) {
try {
int prefixLength = (prefix == null) ? 0 : prefix.length();
int suffixLength = (suffix == null) ? 0 : suffix.length();
return formatter.parse(string.substring(prefixLength, string.length() - suffixLength));
} catch(ParseException e) {
return null;
}
}
}
}
@sirolf2009
Copy link
Author

GapLineChart chart = new GapLineChart(new GapNumberAxis(), new GapNumberAxis());
chart.getData().add(new Series(FXCollections.observableArrayList(
	new Data<Number,Number>(1, 1),
	new Data<Number,Number>(2, 2),
	new Data<Number,Number>(3, 3),
	new Data<Number,Number>(4, Double.NaN),
	new Data<Number,Number>(5, 5),
	new Data<Number,Number>(6, 6)
)));

Becomes
image

@dernasherbrezon
Copy link

Issue with auto ranging could be fixed by overriding updateAxisRange method. See https://github.com/dernasherbrezon/rtlSpectrum/blob/master/src/main/java/ru/r2cloud/rtlspectrum/LineChartWithMarkers.java#L325 where I exclude values with Double.NaN from the list.

Also minor perf issue, only 1 MoveTo required for several Double.NaN. See https://github.com/dernasherbrezon/rtlSpectrum/blob/master/src/main/java/ru/r2cloud/rtlspectrum/LineChartWithMarkers.java#L196

Anyway, thank you for the nice code sample!!

@darmbrust
Copy link

FYI, there is also a bug on line 36-37, if the Nan appears at the end of the chart data, it isn't safe to get the next, you will get an index out of bounds exception. Need to add a out of range check prior to the get.
https://gist.github.com/sirolf2009/ae8a7897b57dcf902b4ed747b05641f9#file-gaplinechart-java-L36-L37

Thanks for publishing the chart, and the other patches above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment