javafx.scene.chart.BarChart Maven / Gradle / Ivy
/*
* Copyright (c) 2010, 2024, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package javafx.scene.chart;
import java.util.*;
import javafx.scene.AccessibleRole;
import javafx.animation.Animation;
import javafx.animation.FadeTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.ParallelTransition;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.property.DoubleProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import com.sun.javafx.charts.Legend.LegendItem;
import javafx.css.StyleableDoubleProperty;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.converter.SizeConverter;
import javafx.collections.ListChangeListener;
import javafx.css.Styleable;
import javafx.css.StyleableProperty;
/**
* A chart that plots bars indicating data values for a category. The bars can be vertical or horizontal depending on
* which axis is a category axis.
*
* Adding data with multiple occurences of a category to a series shows the last occurence.
*
* @param the category axis value type
* @param the data value type
* @since JavaFX 2.0
*/
public class BarChart extends XYChart {
// -------------- PRIVATE FIELDS -------------------------------------------
private Map, Map>> seriesCategoryMap = new HashMap<>();
private final Orientation orientation;
private CategoryAxis categoryAxis;
private ValueAxis valueAxis;
private Timeline dataRemoveTimeline;
private double bottomPos = 0;
private static String NEGATIVE_STYLE = "negative";
private ParallelTransition pt;
// For storing data values in case removed and added immediately.
private Map, Double> XYValueMap =
new HashMap<>();
// -------------- PUBLIC PROPERTIES ----------------------------------------
/** The gap to leave between bars in the same category */
private DoubleProperty barGap = new StyleableDoubleProperty(4) {
@Override protected void invalidated() {
get();
requestChartLayout();
}
@Override
public Object getBean() {
return BarChart.this;
}
@Override
public String getName() {
return "barGap";
}
@Override
public CssMetaData,Number> getCssMetaData() {
return StyleableProperties.BAR_GAP;
}
};
public final double getBarGap() { return barGap.getValue(); }
public final void setBarGap(double value) { barGap.setValue(value); }
public final DoubleProperty barGapProperty() { return barGap; }
/** The gap to leave between bars in separate categories */
private DoubleProperty categoryGap = new StyleableDoubleProperty(10) {
@Override protected void invalidated() {
get();
requestChartLayout();
}
@Override
public Object getBean() {
return BarChart.this;
}
@Override
public String getName() {
return "categoryGap";
}
@Override
public CssMetaData,Number> getCssMetaData() {
return StyleableProperties.CATEGORY_GAP;
}
};
public final double getCategoryGap() { return categoryGap.getValue(); }
public final void setCategoryGap(double value) { categoryGap.setValue(value); }
public final DoubleProperty categoryGapProperty() { return categoryGap; }
// -------------- CONSTRUCTOR ----------------------------------------------
/**
* Construct a new BarChart with the given axis. The two axis should be a ValueAxis/NumberAxis and a CategoryAxis,
* they can be in either order depending on if you want a horizontal or vertical bar chart.
*
* @param xAxis The x axis to use
* @param yAxis The y axis to use
*/
public BarChart(@NamedArg("xAxis") Axis xAxis, @NamedArg("yAxis") Axis yAxis) {
this(xAxis, yAxis, FXCollections.>observableArrayList());
}
/**
* Construct a new BarChart with the given axis and data. The two axis should be a ValueAxis/NumberAxis and a
* CategoryAxis, they can be in either order depending on if you want a horizontal or vertical bar chart.
*
* @param xAxis The x axis to use
* @param yAxis The y axis to use
* @param data The data to use, this is the actual list used so any changes to it will be reflected in the chart
*/
public BarChart(@NamedArg("xAxis") Axis xAxis, @NamedArg("yAxis") Axis yAxis, @NamedArg("data") ObservableList> data) {
super(xAxis, yAxis);
getStyleClass().add("bar-chart");
if (!((xAxis instanceof ValueAxis && yAxis instanceof CategoryAxis) ||
(yAxis instanceof ValueAxis && xAxis instanceof CategoryAxis))) {
throw new IllegalArgumentException("Axis type incorrect, one of X,Y should be CategoryAxis and the other NumberAxis");
}
if (xAxis instanceof CategoryAxis) {
categoryAxis = (CategoryAxis)xAxis;
valueAxis = (ValueAxis)yAxis;
orientation = Orientation.VERTICAL;
} else {
categoryAxis = (CategoryAxis)yAxis;
valueAxis = (ValueAxis)xAxis;
orientation = Orientation.HORIZONTAL;
}
// update css
pseudoClassStateChanged(HORIZONTAL_PSEUDOCLASS_STATE, orientation == Orientation.HORIZONTAL);
pseudoClassStateChanged(VERTICAL_PSEUDOCLASS_STATE, orientation == Orientation.VERTICAL);
setData(data);
}
/**
* Construct a new BarChart with the given axis and data. The two axis should be a ValueAxis/NumberAxis and a
* CategoryAxis, they can be in either order depending on if you want a horizontal or vertical bar chart.
*
* @param xAxis The x axis to use
* @param yAxis The y axis to use
* @param data The data to use, this is the actual list used so any changes to it will be reflected in the chart
* @param categoryGap The gap to leave between bars in separate categories
*/
public BarChart(@NamedArg("xAxis") Axis xAxis, @NamedArg("yAxis") Axis yAxis, @NamedArg("data") ObservableList> data, @NamedArg("categoryGap") double categoryGap) {
this(xAxis, yAxis);
setData(data);
setCategoryGap(categoryGap);
}
// -------------- PROTECTED METHODS ----------------------------------------
@Override protected void dataItemAdded(Series series, int itemIndex, Data item) {
String category;
if (orientation == Orientation.VERTICAL) {
category = (String)item.getXValue();
} else {
category = (String)item.getYValue();
}
Map> categoryMap = seriesCategoryMap.get(series);
if (categoryMap == null) {
categoryMap = new HashMap<>();
seriesCategoryMap.put(series, categoryMap);
}
// check if category is already present
if (!categoryAxis.getCategories().contains(category)) {
int seriesCount = getDataSize();
int categoryCount = categoryAxis.getCategories().size();
int categoryIndex;
if (seriesCount == 1 && itemIndex == categoryCount) {
// shortcut if there is only one series and data contains no duplicates
categoryIndex = categoryCount;
} else {
// There may be data items with duplicate categories. Find category insertion index on the axis
// by looking at the concatenation of the data of all series, skipping duplicate categories.
// The category insertion index is found when the new data's index is reached within its series.
categoryIndex = 0;
var uniqueCategories = new HashSet();
for (var entry : seriesCategoryMap.entrySet()) {
Series s = entry.getKey();
Map> catMap = entry.getValue();
int i = 0;
for (String cat : catMap.keySet()) {
if (s == series && i >= itemIndex) {
break;
}
if (uniqueCategories.add(cat)) {
categoryIndex++;
}
i++;
}
}
}
// note: cat axis categories can be updated only when autoranging is true.
categoryAxis.getCategories().add(categoryIndex, category);
} else if (categoryMap.containsKey(category)){
// RT-21162 : replacing the previous data, first remove the node from scenegraph.
Data data = categoryMap.get(category);
getPlotChildren().remove(data.getNode());
removeDataItemFromDisplay(series, data);
requestChartLayout();
categoryMap.remove(category);
}
categoryMap.put(category, item);
Node bar = createBar(series, getData().indexOf(series), item, itemIndex);
if (shouldAnimate()) {
animateDataAdd(item, bar);
} else {
getPlotChildren().add(bar);
}
}
@Override protected void dataItemRemoved(final Data item, final Series series) {
final Node bar = item.getNode();
if (bar != null) {
bar.focusTraversableProperty().unbind();
}
if (shouldAnimate()) {
XYValueMap.clear();
dataRemoveTimeline = createDataRemoveTimeline(item, bar, series);
dataRemoveTimeline.setOnFinished(event -> {
item.setSeries(null);
removeDataItemFromDisplay(series, item);
});
dataRemoveTimeline.play();
} else {
processDataRemove(series, item);
removeDataItemFromDisplay(series, item);
}
}
/** {@inheritDoc} */
@Override protected void dataItemChanged(Data item) {
double barVal;
double currentVal;
if (orientation == Orientation.VERTICAL) {
barVal = ((Number)item.getYValue()).doubleValue();
currentVal = ((Number)item.getCurrentY()).doubleValue();
} else {
barVal = ((Number)item.getXValue()).doubleValue();
currentVal = ((Number)item.getCurrentX()).doubleValue();
}
if (currentVal > 0 && barVal < 0) { // going from positive to negative
// add style class negative
item.getNode().getStyleClass().add(NEGATIVE_STYLE);
} else if (currentVal < 0 && barVal > 0) { // going from negative to positive
// remove style class negative
// RT-21164 upside down bars: was adding NEGATIVE_STYLE styleclass
// instead of removing it; when going from negative to positive
item.getNode().getStyleClass().remove(NEGATIVE_STYLE);
}
}
@Override protected void seriesChanged(ListChangeListener.Change extends Series> c) {
// Update style classes for all series lines and symbols
// Note: is there a more efficient way of doing this?
for (int i = 0; i < getDataSize(); i++) {
final Series series = getData().get(i);
for (int j=0; j item = series.getData().get(j);
Node bar = item.getNode();
bar.getStyleClass().setAll("chart-bar", "series" + i, "data" + j, series.defaultColorStyleClass);
applyNegativeStyleClass(item);
}
}
}
@Override protected void seriesAdded(Series series, int seriesIndex) {
// handle any data already in series
for (int j=0; j item = series.getData().get(j);
dataItemAdded(series, j, item);
}
}
@Override protected void seriesRemoved(final Series series) {
// remove all symbol nodes
if (shouldAnimate()) {
pt = new ParallelTransition();
pt.setOnFinished(event -> {
removeSeriesFromDisplay(series);
});
XYValueMap.clear();
for (final Data d : series.getData()) {
final Node bar = d.getNode();
// Animate series deletion
if (getSeriesSize() > 1) {
Timeline t = createDataRemoveTimeline(d, bar, series);
pt.getChildren().add(t);
} else {
// fade out last series
FadeTransition ft = new FadeTransition(Duration.millis(700),bar);
ft.setFromValue(1);
ft.setToValue(0);
ft.setOnFinished(actionEvent -> {
processDataRemove(series, d);
bar.setOpacity(1.0);
});
pt.getChildren().add(ft);
}
}
pt.play();
} else {
for (Data d : series.getData()) {
processDataRemove(series, d);
}
removeSeriesFromDisplay(series);
}
}
/** {@inheritDoc} */
@Override protected void layoutPlotChildren() {
double catSpace = categoryAxis.getCategorySpacing();
// calculate bar spacing
final double availableBarSpace = catSpace - getCategoryGap() + getBarGap();
double barWidth = (availableBarSpace / getSeriesSize()) - getBarGap();
final double barOffset = -((catSpace - getCategoryGap()) / 2);
final double zeroPos = (valueAxis.getLowerBound() > 0) ?
valueAxis.getDisplayPosition(valueAxis.getLowerBound()) : valueAxis.getZeroPosition();
// RT-24813 : if the data in a series gets too large, barWidth can get negative.
if (barWidth <= 0) barWidth = 1;
// update bar positions and sizes
int catIndex = 0;
for (String category : categoryAxis.getCategories()) {
int index = 0;
for (Iterator> sit = getDisplayedSeriesIterator(); sit.hasNext(); ) {
Series series = sit.next();
final Data item = getDataItem(series, index, catIndex, category);
if (item != null) {
final Node bar = item.getNode();
final double categoryPos;
final double valPos;
if (orientation == Orientation.VERTICAL) {
categoryPos = getXAxis().getDisplayPosition(item.getCurrentX());
valPos = getYAxis().getDisplayPosition(item.getCurrentY());
} else {
categoryPos = getYAxis().getDisplayPosition(item.getCurrentY());
valPos = getXAxis().getDisplayPosition(item.getCurrentX());
}
if (Double.isNaN(categoryPos) || Double.isNaN(valPos)) {
continue;
}
final double bottom = Math.min(valPos,zeroPos);
final double top = Math.max(valPos,zeroPos);
bottomPos = bottom;
if (orientation == Orientation.VERTICAL) {
bar.resizeRelocate( categoryPos + barOffset + (barWidth + getBarGap()) * index,
bottom, barWidth, top-bottom);
} else {
//noinspection SuspiciousNameCombination
bar.resizeRelocate( bottom, categoryPos + barOffset + (barWidth + getBarGap()) * index,
top-bottom, barWidth);
}
}
index++;
}
catIndex++;
}
}
@Override
LegendItem createLegendItemForSeries(Series series, int seriesIndex) {
LegendItem legendItem = new LegendItem(series.getName());
legendItem.getSymbol().getStyleClass().addAll("chart-bar", "series" + seriesIndex,
"bar-legend-symbol", series.defaultColorStyleClass);
return legendItem;
}
// -------------- PRIVATE METHODS ------------------------------------------
private void updateMap(Series series, Data item) {
final String category = (orientation == Orientation.VERTICAL) ? (String)item.getXValue() :
(String)item.getYValue();
Map> categoryMap = seriesCategoryMap.get(series);
if (categoryMap != null) {
categoryMap.remove(category);
if (categoryMap.isEmpty()) seriesCategoryMap.remove(series);
}
if (seriesCategoryMap.isEmpty() && categoryAxis.isAutoRanging()) categoryAxis.getCategories().clear();
}
private void processDataRemove(final Series series, final Data item) {
Node bar = item.getNode();
getPlotChildren().remove(bar);
updateMap(series, item);
}
private void animateDataAdd(Data item, Node bar) {
double barVal;
if (orientation == Orientation.VERTICAL) {
barVal = ((Number)item.getYValue()).doubleValue();
item.setCurrentY(getYAxis().toRealValue((barVal < 0) ? -bottomPos : bottomPos));
getPlotChildren().add(bar);
item.setYValue(getYAxis().toRealValue(barVal));
animate(
new KeyFrame(Duration.ZERO, new KeyValue(
item.currentYProperty(),
item.getCurrentY())),
new KeyFrame(Duration.millis(700), new KeyValue(
item.currentYProperty(),
item.getYValue(), Interpolator.EASE_BOTH))
);
} else {
barVal = ((Number)item.getXValue()).doubleValue();
item.setCurrentX(getXAxis().toRealValue((barVal < 0) ? -bottomPos : bottomPos));
getPlotChildren().add(bar);
item.setXValue(getXAxis().toRealValue(barVal));
animate(
new KeyFrame(Duration.ZERO, new KeyValue(
item.currentXProperty(),
item.getCurrentX())),
new KeyFrame(Duration.millis(700), new KeyValue(
item.currentXProperty(),
item.getXValue(), Interpolator.EASE_BOTH))
);
}
}
private Timeline createDataRemoveTimeline(final Data item, final Node bar, final Series series) {
Timeline t = new Timeline();
if (orientation == Orientation.VERTICAL) {
// item.setYValue(getYAxis().toRealValue(getYAxis().getZeroPosition()));
// save data values in case the same data item gets added immediately.
XYValueMap.put(item, ((Number)item.getYValue()).doubleValue());
item.setYValue(getYAxis().toRealValue(bottomPos));
t.getKeyFrames().addAll(
new KeyFrame(Duration.ZERO, new KeyValue(
item.currentYProperty(), item.getCurrentY())),
new KeyFrame(Duration.millis(700), actionEvent -> {
processDataRemove(series, item);
XYValueMap.clear();
}, new KeyValue(
item.currentYProperty(),
item.getYValue(), Interpolator.EASE_BOTH))
);
} else {
// save data values in case the same data item gets added immediately.
XYValueMap.put(item, ((Number)item.getXValue()).doubleValue());
item.setXValue(getXAxis().toRealValue(getXAxis().getZeroPosition()));
t.getKeyFrames().addAll(
new KeyFrame(Duration.ZERO, new KeyValue(
item.currentXProperty(), item.getCurrentX())),
new KeyFrame(Duration.millis(700), actionEvent -> {
processDataRemove(series, item);
XYValueMap.clear();
}, new KeyValue(
item.currentXProperty(),
item.getXValue(), Interpolator.EASE_BOTH))
);
}
return t;
}
@Override void dataBeingRemovedIsAdded(Data item, Series series) {
if (dataRemoveTimeline != null) {
dataRemoveTimeline.setOnFinished(null);
dataRemoveTimeline.stop();
}
processDataRemove(series, item);
item.setSeries(null);
removeDataItemFromDisplay(series, item);
restoreDataValues(item);
XYValueMap.clear();
}
private void restoreDataValues(Data item) {
Double value = XYValueMap.get(item);
if (value != null) {
// Restoring original X/Y values
if (orientation.equals(Orientation.VERTICAL)) {
item.setYValue(value);
item.setCurrentY(value);
} else {
item.setXValue(value);
item.setCurrentX(value);
}
}
}
@Override void seriesBeingRemovedIsAdded(Series series) {
boolean lastSeries = (pt.getChildren().size() == 1) ? true : false;
if (pt!= null) {
if (!pt.getChildren().isEmpty()) {
for (Animation a : pt.getChildren()) {
a.setOnFinished(null);
}
}
for (Data item : series.getData()) {
processDataRemove(series, item);
if (!lastSeries) {
restoreDataValues(item);
}
}
XYValueMap.clear();
pt.setOnFinished(null);
pt.getChildren().clear();
pt.stop();
removeSeriesFromDisplay(series);
}
}
private Node createBar(Series series, int seriesIndex, final Data item, int itemIndex) {
Node bar = item.getNode();
if (bar == null) {
bar = new StackPane();
bar.setAccessibleRole(AccessibleRole.TEXT);
bar.setAccessibleRoleDescription("Bar");
bar.focusTraversableProperty().bind(Platform.accessibilityActiveProperty());
item.setNode(bar);
}
bar.getStyleClass().setAll("chart-bar", "series" + seriesIndex, "data" + itemIndex, series.defaultColorStyleClass);
applyNegativeStyleClass(item);
return bar;
}
private void applyNegativeStyleClass(Data item) {
double barVal = (orientation == Orientation.VERTICAL) ? ((Number)item.getYValue()).doubleValue() :
((Number)item.getXValue()).doubleValue();
if (barVal < 0) {
var bar = item.getNode();
bar.getStyleClass().add(NEGATIVE_STYLE);
}
}
private Data getDataItem(Series series, int seriesIndex, int itemIndex, String category) {
Map> catmap = seriesCategoryMap.get(series);
return (catmap != null) ? catmap.get(category) : null;
}
// -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------
/*
* Super-lazy instantiation pattern from Bill Pugh.
*/
private static class StyleableProperties {
private static final CssMetaData,Number> BAR_GAP =
new CssMetaData<>("-fx-bar-gap",
SizeConverter.getInstance(), 4.0) {
@Override
public boolean isSettable(BarChart,?> node) {
return node.barGap == null || !node.barGap.isBound();
}
@Override
public StyleableProperty getStyleableProperty(BarChart,?> node) {
return (StyleableProperty)node.barGapProperty();
}
};
private static final CssMetaData,Number> CATEGORY_GAP =
new CssMetaData<>("-fx-category-gap",
SizeConverter.getInstance(), 10.0) {
@Override
public boolean isSettable(BarChart,?> node) {
return node.categoryGap == null || !node.categoryGap.isBound();
}
@Override
public StyleableProperty getStyleableProperty(BarChart,?> node) {
return (StyleableProperty)node.categoryGapProperty();
}
};
private static final List> STYLEABLES;
static {
final List> styleables =
new ArrayList<>(XYChart.getClassCssMetaData());
styleables.add(BAR_GAP);
styleables.add(CATEGORY_GAP);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
/**
* Gets the {@code CssMetaData} associated with this class, which may include the
* {@code CssMetaData} of its superclasses.
* @return the {@code CssMetaData}
* @since JavaFX 8.0
*/
public static List> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
/**
* {@inheritDoc}
* @since JavaFX 8.0
*/
@Override
public List> getCssMetaData() {
return getClassCssMetaData();
}
/** Pseudoclass indicating this is a vertical chart. */
private static final PseudoClass VERTICAL_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("vertical");
/** Pseudoclass indicating this is a horizontal chart. */
private static final PseudoClass HORIZONTAL_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("horizontal");
}