javafx.scene.chart.Axis 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 com.sun.javafx.scene.NodeHelper;
import javafx.css.Styleable;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.StyleableBooleanProperty;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleableObjectProperty;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.EnumConverter;
import javafx.css.converter.PaintConverter;
import javafx.css.converter.SizeConverter;
import java.util.*;
import javafx.animation.FadeTransition;
import javafx.beans.binding.DoubleExpression;
import javafx.beans.binding.ObjectExpression;
import javafx.beans.binding.StringExpression;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.FontCssMetaData;
import javafx.css.StyleableProperty;
import javafx.geometry.Bounds;
import javafx.geometry.Dimension2D;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.geometry.Side;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Translate;
import javafx.util.Duration;
/**
* Base class for all axes in JavaFX that represents an axis drawn on a chart area.
* It holds properties for axis auto ranging, ticks and labels along the axis.
*
* Some examples of concrete subclasses include {@link NumberAxis} whose axis plots data
* in numbers and {@link CategoryAxis} whose values / ticks represent string
* categories along its axis.
*
* @param the axis data type
* @since JavaFX 2.0
*/
public abstract class Axis extends Region {
// -------------- PRIVATE FIELDS -----------------------------------------------------------------------------------
Text measure = new Text();
private Orientation effectiveOrientation;
private double effectiveTickLabelRotation = Double.NaN;
private Label axisLabel = new Label();
private final Path tickMarkPath = new Path();
private double oldLength = 0;
/** True when the current range invalid and all dependent calculations need to be updated */
boolean rangeValid = false;
boolean measureInvalid = false;
boolean tickLabelsVisibleInvalid = false;
private BitSet labelsToSkip = new BitSet();
// -------------- PUBLIC PROPERTIES --------------------------------------------------------------------------------
private final ObservableList> tickMarks = FXCollections.observableArrayList();
private final ObservableList> unmodifiableTickMarks = FXCollections.unmodifiableObservableList(tickMarks);
/**
* Unmodifiable observable list of tickmarks, each TickMark directly representing a tickmark on this axis. This is updated
* whenever the displayed tickmarks changes.
*
* @return Unmodifiable observable list of TickMarks on this axis
*/
public ObservableList> getTickMarks() { return unmodifiableTickMarks; }
/** The side of the plot which this axis is being drawn on */
private ObjectProperty side = new StyleableObjectProperty<>(){
@Override protected void invalidated() {
// cause refreshTickMarks
Side edge = get();
pseudoClassStateChanged(TOP_PSEUDOCLASS_STATE, edge == Side.TOP);
pseudoClassStateChanged(RIGHT_PSEUDOCLASS_STATE, edge == Side.RIGHT);
pseudoClassStateChanged(BOTTOM_PSEUDOCLASS_STATE, edge == Side.BOTTOM);
pseudoClassStateChanged(LEFT_PSEUDOCLASS_STATE, edge == Side.LEFT);
requestAxisLayout();
}
@Override
public CssMetaData,Side> getCssMetaData() {
return StyleableProperties.SIDE;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "side";
}
};
public final Side getSide() { return side.get(); }
public final void setSide(Side value) { side.set(value); }
public final ObjectProperty sideProperty() { return side; }
final void setEffectiveOrientation(Orientation orientation) {
effectiveOrientation = orientation;
}
final Side getEffectiveSide() {
final Side side = getSide();
if (side == null || (side.isVertical() && effectiveOrientation == Orientation.HORIZONTAL)
|| side.isHorizontal() && effectiveOrientation == Orientation.VERTICAL) {
// Means side == null && effectiveOrientation == null produces Side.BOTTOM
return effectiveOrientation == Orientation.VERTICAL ? Side.LEFT : Side.BOTTOM;
}
return side;
}
/** The axis label */
private ObjectProperty label = new ObjectPropertyBase<>() {
@Override protected void invalidated() {
axisLabel.setText(get());
requestAxisLayout();
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "label";
}
};
public final String getLabel() { return label.get(); }
public final void setLabel(String value) { label.set(value); }
public final ObjectProperty labelProperty() { return label; }
/** true if tick marks should be displayed */
private BooleanProperty tickMarkVisible = new StyleableBooleanProperty(true) {
@Override protected void invalidated() {
tickMarkPath.setVisible(get());
requestAxisLayout();
}
@Override
public CssMetaData,Boolean> getCssMetaData() {
return StyleableProperties.TICK_MARK_VISIBLE;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickMarkVisible";
}
};
public final boolean isTickMarkVisible() { return tickMarkVisible.get(); }
public final void setTickMarkVisible(boolean value) { tickMarkVisible.set(value); }
public final BooleanProperty tickMarkVisibleProperty() { return tickMarkVisible; }
/** true if tick mark labels should be displayed */
private BooleanProperty tickLabelsVisible = new StyleableBooleanProperty(true) {
@Override protected void invalidated() {
// update textNode visibility for each tick
for (TickMark tick : tickMarks) {
tick.setTextVisible(get());
}
tickLabelsVisibleInvalid = true;
requestAxisLayout();
}
@Override
public CssMetaData,Boolean> getCssMetaData() {
return StyleableProperties.TICK_LABELS_VISIBLE;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLabelsVisible";
}
};
public final boolean isTickLabelsVisible() { return tickLabelsVisible.get(); }
public final void setTickLabelsVisible(boolean value) {
tickLabelsVisible.set(value); }
public final BooleanProperty tickLabelsVisibleProperty() { return tickLabelsVisible; }
/** The length of tick mark lines */
private DoubleProperty tickLength = new StyleableDoubleProperty(8) {
@Override protected void invalidated() {
if (tickLength.get() < 0 && !tickLength.isBound()) {
tickLength.set(0);
}
// this effects preferred size so request layout
requestAxisLayout();
}
@Override
public CssMetaData,Number> getCssMetaData() {
return StyleableProperties.TICK_LENGTH;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLength";
}
};
public final double getTickLength() { return tickLength.get(); }
public final void setTickLength(double value) { tickLength.set(value); }
public final DoubleProperty tickLengthProperty() { return tickLength; }
/** This is true when the axis determines its range from the data automatically */
private BooleanProperty autoRanging = new BooleanPropertyBase(true) {
@Override protected void invalidated() {
if(get()) {
// auto range turned on, so need to auto range now
// autoRangeValid = false;
requestAxisLayout();
}
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "autoRanging";
}
};
public final boolean isAutoRanging() { return autoRanging.get(); }
public final void setAutoRanging(boolean value) { autoRanging.set(value); }
public final BooleanProperty autoRangingProperty() { return autoRanging; }
/** The font for all tick labels */
private ObjectProperty tickLabelFont = new StyleableObjectProperty(Font.font("System",8)) {
@Override protected void invalidated() {
Font f = get();
measure.setFont(f);
for(TickMark tm : getTickMarks()) {
tm.textNode.setFont(f);
}
measureInvalid = true;
requestAxisLayout();
}
@Override
public CssMetaData,Font> getCssMetaData() {
return StyleableProperties.TICK_LABEL_FONT;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLabelFont";
}
};
public final Font getTickLabelFont() { return tickLabelFont.get(); }
public final void setTickLabelFont(Font value) { tickLabelFont.set(value); }
public final ObjectProperty tickLabelFontProperty() { return tickLabelFont; }
/** The fill for all tick labels */
private ObjectProperty tickLabelFill = new StyleableObjectProperty(Color.BLACK) {
@Override protected void invalidated() {
for (TickMark tick : tickMarks) {
tick.textNode.setFill(getTickLabelFill());
}
}
@Override
public CssMetaData,Paint> getCssMetaData() {
return StyleableProperties.TICK_LABEL_FILL;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLabelFill";
}
};
public final Paint getTickLabelFill() { return tickLabelFill.get(); }
public final void setTickLabelFill(Paint value) { tickLabelFill.set(value); }
public final ObjectProperty tickLabelFillProperty() { return tickLabelFill; }
/** The gap between tick labels and the tick mark lines */
private DoubleProperty tickLabelGap = new StyleableDoubleProperty(3) {
@Override protected void invalidated() {
requestAxisLayout();
}
@Override
public CssMetaData,Number> getCssMetaData() {
return StyleableProperties.TICK_LABEL_TICK_GAP;
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLabelGap";
}
};
public final double getTickLabelGap() { return tickLabelGap.get(); }
public final void setTickLabelGap(double value) { tickLabelGap.set(value); }
public final DoubleProperty tickLabelGapProperty() { return tickLabelGap; }
/**
* When true any changes to the axis and its range will be animated.
*/
private BooleanProperty animated = new SimpleBooleanProperty(this, "animated", true);
/**
* Indicates whether the changes to axis range will be animated or not.
*
* @return true if axis range changes will be animated and false otherwise
*/
public final boolean getAnimated() { return animated.get(); }
public final void setAnimated(boolean value) { animated.set(value); }
public final BooleanProperty animatedProperty() { return animated; }
/**
* Rotation in degrees of tick mark labels from their normal horizontal.
*/
private DoubleProperty tickLabelRotation = new DoublePropertyBase(0) {
@Override protected void invalidated() {
if (isAutoRanging()) {
invalidateRange(); // NumberAxis and CategoryAxis use this property in autorange
}
requestAxisLayout();
}
@Override
public Object getBean() {
return Axis.this;
}
@Override
public String getName() {
return "tickLabelRotation";
}
};
public final double getTickLabelRotation() { return tickLabelRotation.getValue(); }
public final void setTickLabelRotation(double value) { tickLabelRotation.setValue(value); }
public final DoubleProperty tickLabelRotationProperty() { return tickLabelRotation; }
// -------------- CONSTRUCTOR --------------------------------------------------------------------------------------
/**
* Creates and initializes a new instance of the Axis class.
*/
public Axis() {
getStyleClass().setAll("axis");
axisLabel.getStyleClass().add("axis-label");
axisLabel.setAlignment(Pos.CENTER);
tickMarkPath.getStyleClass().add("axis-tick-mark");
getChildren().addAll(axisLabel, tickMarkPath);
}
// -------------- METHODS ------------------------------------------------------------------------------------------
/**
* See if the current range is valid, if it is not then any range dependent calulcations need to redone on the next layout pass
*
* @return true if current range calculations are valid
*/
protected final boolean isRangeValid() { return rangeValid; }
/**
* Mark the current range invalid, this will cause anything that depends on the range to be recalculated on the
* next layout.
*/
protected final void invalidateRange() { rangeValid = false; }
/**
* This is used to check if any given animation should run. It returns true if animation is enabled and the node
* is visible and in a scene.
*
* @return true if animations should happen
*/
protected final boolean shouldAnimate(){
return getAnimated() && NodeHelper.isTreeShowing(this);
}
/**
* We suppress requestLayout() calls here by doing nothing as we don't want changes to our children to cause
* layout. If you really need to request layout then call requestAxisLayout().
*/
@Override public void requestLayout() {}
/**
* Request that the axis is laid out in the next layout pass. This replaces requestLayout() as it has been
* overridden to do nothing so that changes to children's bounds etc do not cause a layout. This was done as a
* optimization as the Axis knows the exact minimal set of changes that really need layout to be updated. So we
* only want to request layout then, not on any child change.
*/
public void requestAxisLayout() {
super.requestLayout();
}
/**
* Called when data has changed and the range may not be valid any more. This is only called by the chart if
* isAutoRanging() returns true. If we are auto ranging it will cause layout to be requested and auto ranging to
* happen on next layout pass.
*
* @param data The current set of all data that needs to be plotted on this axis
*/
public void invalidateRange(List data) {
invalidateRange();
requestAxisLayout();
}
/**
* This calculates the upper and lower bound based on the data provided to invalidateRange() method. This must not
* effect the state of the axis, changing any properties of the axis. Any results of the auto-ranging should be
* returned in the range object. This will we passed to setRange() if it has been decided to adopt this range for
* this axis.
*
* @param length The length of the axis in screen coordinates
* @return Range information, this is implementation dependent
*/
protected abstract Object autoRange(double length);
/**
* 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
*/
protected abstract void setRange(Object range, boolean animate);
/**
* Called to get the current axis range.
*
* @return A range object that can be passed to setRange() and calculateTickValues()
*/
protected abstract Object getRange();
/**
* Get the display position of the zero line along this axis.
*
* @return display position or Double.NaN if zero is not in current range;
*/
public abstract double getZeroPosition();
/**
* 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.
*
* If the value is not valid for this Axis and the axis cannot display such value in any range,
* Double.NaN is returned
*
* @param value The data value to work out display position for
* @return display position or Double.NaN if value not valid
*/
public abstract double getDisplayPosition(T value);
/**
* Get the data value for the given display position on this axis. If the axis
* is a CategoryAxis this will be the nearest value.
*
* @param displayPosition A pixel position on this axis
* @return the nearest data value to the given pixel position or
* null if not on axis;
*/
public abstract T getValueForDisplay(double displayPosition);
/**
* 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
*/
public abstract boolean isValueOnAxis(T value);
/**
* All axis values must be representable by some numeric value. This gets the numeric value for a given data value.
*
* @param value The data value to convert
* @return Numeric value for the given data value
*/
public abstract double toNumericValue(T value);
/**
* All axis values must be representable by some numeric value. This gets the data value for a given numeric value.
*
* @param value The numeric value to convert
* @return Data value for given numeric value
*/
public abstract T toRealValue(double value);
/**
* 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
*/
protected abstract List calculateTickValues(double length, Object range);
/**
* Computes the preferred height of this axis for the given width. If axis orientation
* is horizontal, it takes into account the tick mark length, tick label gap and
* label height.
*
* @return the computed preferred width for this axis
*/
@Override protected double computePrefHeight(double width) {
final Side side = getEffectiveSide();
if (side.isVertical()) {
// TODO for now we have no hard and fast answer here, I guess it should work
// TODO out the minimum size needed to display min, max and zero tick mark labels.
return 100;
} else { // HORIZONTAL
// we need to first auto range as this may/will effect tick marks
Object range = autoRange(width);
// calculate max tick label height
double maxLabelHeight = 0;
// calculate the new tick marks
if (isTickLabelsVisible()) {
final List newTickValues = calculateTickValues(width, range);
for (T value: newTickValues) {
maxLabelHeight = Math.max(maxLabelHeight,measureTickMarkSize(value, range).getHeight());
}
}
// calculate tick mark length
final double tickMarkLength = isTickMarkVisible() ? (getTickLength() > 0) ? getTickLength() : 0 : 0;
// calculate label height
final double labelHeight =
axisLabel.getText() == null || axisLabel.getText().length() == 0 ?
0 : axisLabel.prefHeight(-1);
return maxLabelHeight + getTickLabelGap() + tickMarkLength + labelHeight;
}
}
/**
* Computes the preferred width of this axis for the given height. If axis orientation
* is vertical, it takes into account the tick mark length, tick label gap and
* label height.
*
* @return the computed preferred width for this axis
*/
@Override protected double computePrefWidth(double height) {
final Side side = getEffectiveSide();
if (side.isVertical()) {
// we need to first auto range as this may/will effect tick marks
Object range = autoRange(height);
// calculate max tick label width
double maxLabelWidth = 0;
// calculate the new tick marks
if (isTickLabelsVisible()) {
final List newTickValues = calculateTickValues(height,range);
for (T value: newTickValues) {
maxLabelWidth = Math.max(maxLabelWidth, measureTickMarkSize(value, range).getWidth());
}
}
// calculate tick mark length
final double tickMarkLength = isTickMarkVisible() ? (getTickLength() > 0) ? getTickLength() : 0 : 0;
// calculate label height
final double labelHeight =
axisLabel.getText() == null || axisLabel.getText().length() == 0 ?
0 : axisLabel.prefHeight(-1);
return maxLabelWidth + getTickLabelGap() + tickMarkLength + labelHeight;
} else { // HORIZONTAL
// TODO for now we have no hard and fast answer here, I guess it should work
// TODO out the minimum size needed to display min, max and zero tick mark labels.
return 100;
}
}
/**
* Called during layout if the tickmarks have been updated, allowing subclasses to do anything they need to
* in reaction.
*/
protected void tickMarksUpdated(){}
/**
* Invoked during the layout pass to layout this axis and all its content.
*/
@Override protected void layoutChildren() {
final boolean isFirstPass = oldLength == 0;
// auto range if it is not valid
final Side side = getEffectiveSide();
final double length = side.isVertical() ? getHeight() : getWidth();
boolean rangeInvalid = !isRangeValid();
boolean lengthDiffers = oldLength != length;
if (lengthDiffers || rangeInvalid) {
// get range
Object range;
if(isAutoRanging()) {
// auto range
range = autoRange(length);
// set current range to new range
setRange(range, getAnimated() && !isFirstPass && NodeHelper.isTreeShowing(this) && rangeInvalid);
} else {
range = getRange();
}
// calculate new tick marks
List newTickValues = calculateTickValues(length, range);
// remove everything
Iterator> tickMarkIterator = tickMarks.iterator();
while (tickMarkIterator.hasNext()) {
TickMark tick = tickMarkIterator.next();
final TickMark tm = tick;
if (shouldAnimate()) {
FadeTransition ft = new FadeTransition(Duration.millis(250),tick.textNode);
ft.setToValue(0);
ft.setOnFinished(actionEvent -> {
getChildren().remove(tm.textNode);
});
ft.play();
} else {
getChildren().remove(tm.textNode);
}
// we have to remove the tick mark immediately so we don't draw tick line for it or grid lines and fills
tickMarkIterator.remove();
}
// add new tick marks for new values
for(T newValue: newTickValues) {
final TickMark tick = new TickMark<>();
tick.setValue(newValue);
tick.textNode.setText(getTickMarkLabel(newValue));
tick.textNode.setFont(getTickLabelFont());
tick.textNode.setFill(getTickLabelFill());
tick.setTextVisible(isTickLabelsVisible());
if (shouldAnimate()) tick.textNode.setOpacity(0);
getChildren().add(tick.textNode);
tickMarks.add(tick);
if (shouldAnimate()) {
FadeTransition ft = new FadeTransition(Duration.millis(750),tick.textNode);
ft.setFromValue(0);
ft.setToValue(1);
ft.play();
}
}
// mark all done
oldLength = length;
rangeValid = true;
}
if (lengthDiffers || rangeInvalid || measureInvalid || tickLabelsVisibleInvalid) {
measureInvalid = false;
tickLabelsVisibleInvalid = false;
// RT-12272 : tick labels overlapping
// first check if all visible labels fit, if not, retain every nth label
labelsToSkip.clear();
int numLabelsToSkip = 0;
double totalLabelsSize = 0;
double maxLabelSize = 0;
for (TickMark m : tickMarks) {
m.setPosition(getDisplayPosition(m.getValue()));
if (m.isTextVisible()) {
double tickSize = measureTickMarkSize(m.getValue(), side);
totalLabelsSize += tickSize;
maxLabelSize = Math.round(Math.max(maxLabelSize, tickSize));
}
}
if (maxLabelSize > 0 && length < totalLabelsSize) {
numLabelsToSkip = ((int)(tickMarks.size() * maxLabelSize / length)) + 1;
}
if (numLabelsToSkip > 0) {
int tickIndex = 0;
for (TickMark m : tickMarks) {
if (m.isTextVisible()) {
m.setTextVisible((tickIndex++ % numLabelsToSkip) == 0);
}
}
}
// now check if labels for bounds overlap nearby labels, this can happen due to JDK-8097501
// use tickLabelGap to prevent sticking
if (tickMarks.size() > 2) {
TickMark m1 = tickMarks.get(0);
TickMark m2 = tickMarks.get(1);
if (isTickLabelsOverlap(side, m1, m2, getTickLabelGap())) {
m2.setTextVisible(false);
}
m1 = tickMarks.get(tickMarks.size()-2);
m2 = tickMarks.get(tickMarks.size()-1);
if (isTickLabelsOverlap(side, m1, m2, getTickLabelGap())) {
m1.setTextVisible(false);
}
}
updateTickMarks(side, length);
// call tick marks updated to inform subclasses that we have updated tick marks
tickMarksUpdated();
}
}
private void updateTickMarks(Side side, double length) {
// clear tick mark path elements as we will recreate
tickMarkPath.getElements().clear();
// do layout of axis label, tick mark lines and text
final double width = getWidth();
final double height = getHeight();
final double tickMarkLength = (isTickMarkVisible() && getTickLength() > 0) ? getTickLength() : 0;
final double effectiveLabelRotation = getEffectiveTickLabelRotation();
if (Side.LEFT.equals(side)) {
// offset path to make strokes snap to pixel
tickMarkPath.setLayoutX(-0.5);
tickMarkPath.setLayoutY(0.5);
if (getLabel() != null) {
axisLabel.getTransforms().setAll(new Translate(0, height), new Rotate(-90, 0, 0));
axisLabel.setLayoutX(0);
axisLabel.setLayoutY(0);
//noinspection SuspiciousNameCombination
axisLabel.resize(height, Math.ceil(axisLabel.prefHeight(width)));
}
for (TickMark tick : tickMarks) {
positionTextNode(tick.textNode, width - getTickLabelGap() - tickMarkLength,
tick.getPosition(), effectiveLabelRotation, side);
updateTickMark(tick, length,
width - tickMarkLength, tick.getPosition(),
width, tick.getPosition());
}
} else if (Side.RIGHT.equals(side)) {
// offset path to make strokes snap to pixel
tickMarkPath.setLayoutX(0.5);
tickMarkPath.setLayoutY(0.5);
if (getLabel() != null) {
final double axisLabelWidth = Math.ceil(axisLabel.prefHeight(width));
axisLabel.getTransforms().setAll(new Translate(0, height), new Rotate(-90, 0, 0));
axisLabel.setLayoutX(width-axisLabelWidth);
axisLabel.setLayoutY(0);
//noinspection SuspiciousNameCombination
axisLabel.resize(height, axisLabelWidth);
}
for (TickMark tick : tickMarks) {
positionTextNode(tick.textNode, getTickLabelGap() + tickMarkLength,
tick.getPosition(), effectiveLabelRotation, side);
updateTickMark(tick, length,
0, tick.getPosition(),
tickMarkLength, tick.getPosition());
}
} else if (Side.TOP.equals(side)) {
// offset path to make strokes snap to pixel
tickMarkPath.setLayoutX(0.5);
tickMarkPath.setLayoutY(-0.5);
if (getLabel() != null) {
axisLabel.getTransforms().clear();
axisLabel.setLayoutX(0);
axisLabel.setLayoutY(0);
axisLabel.resize(width, Math.ceil(axisLabel.prefHeight(width)));
}
for (TickMark tick : tickMarks) {
positionTextNode(tick.textNode, tick.getPosition(), height - tickMarkLength - getTickLabelGap(),
effectiveLabelRotation, side);
updateTickMark(tick, length,
tick.getPosition(), height,
tick.getPosition(), height - tickMarkLength);
}
} else {
// BOTTOM
// offset path to make strokes snap to pixel
tickMarkPath.setLayoutX(0.5);
tickMarkPath.setLayoutY(0.5);
if (getLabel() != null) {
axisLabel.getTransforms().clear();
final double labelHeight = Math.ceil(axisLabel.prefHeight(width));
axisLabel.setLayoutX(0);
axisLabel.setLayoutY(height - labelHeight);
axisLabel.resize(width, labelHeight);
}
for (TickMark tick : tickMarks) {
positionTextNode(tick.textNode, tick.getPosition(), tickMarkLength + getTickLabelGap(),
effectiveLabelRotation, side);
updateTickMark(tick, length,
tick.getPosition(), 0,
tick.getPosition(), tickMarkLength);
}
}
}
/**
* Checks if two consecutive tick mark labels overlaps.
* @param side side of the Axis
* @param m1 first tick mark
* @param m2 second tick mark
* @param gap minimum space between labels
* @return true if labels overlap
*/
private boolean isTickLabelsOverlap(Side side, TickMark m1, TickMark m2, double gap) {
if (!m1.isTextVisible() || !m2.isTextVisible()) return false;
double m1Size = measureTickMarkSize(m1.getValue(), side);
double m2Size = measureTickMarkSize(m2.getValue(), side);
double m1Start = m1.getPosition() - m1Size / 2;
double m1End = m1.getPosition() + m1Size / 2;
double m2Start = m2.getPosition() - m2Size / 2;
double m2End = m2.getPosition() + m2Size / 2;
return side.isVertical() ? (m1Start - m2End) <= gap : (m2Start - m1End) <= gap;
}
/**
* Positions a text node to one side of the given point, it X height is vertically centered on point if LEFT or
* RIGHT and its centered horizontally if TOP ot BOTTOM.
*
* @param node The text node to position
* @param posX The x position, to place text next to
* @param posY The y position, to place text next to
* @param angle The text rotation
* @param side The side to place text next to position x,y at
*/
private void positionTextNode(Text node, double posX, double posY, double angle, Side side) {
node.setLayoutX(0);
node.setLayoutY(0);
node.setRotate(angle);
final Bounds bounds = node.getBoundsInParent();
if (Side.LEFT.equals(side)) {
node.setLayoutX(posX-bounds.getWidth()-bounds.getMinX());
node.setLayoutY(posY - (bounds.getHeight() / 2d) - bounds.getMinY());
} else if (Side.RIGHT.equals(side)) {
node.setLayoutX(posX-bounds.getMinX());
node.setLayoutY(posY-(bounds.getHeight()/2d)-bounds.getMinY());
} else if (Side.TOP.equals(side)) {
node.setLayoutX(posX-(bounds.getWidth()/2d)-bounds.getMinX());
node.setLayoutY(posY-bounds.getHeight()-bounds.getMinY());
} else {
node.setLayoutX(posX-(bounds.getWidth()/2d)-bounds.getMinX());
node.setLayoutY(posY-bounds.getMinY());
}
}
/**
* Updates visibility of the text node and adds the tick mark to the path
*/
private void updateTickMark(TickMark tick, double length,
double startX, double startY, double endX, double endY)
{
// check if position is inside bounds
if (tick.getPosition() >= 0 && tick.getPosition() <= Math.ceil(length)) {
tick.textNode.setVisible(tick.isTextVisible());
// add tick mark line
tickMarkPath.getElements().addAll(
new MoveTo(startX, startY),
new LineTo(endX, endY)
);
} else {
tick.textNode.setVisible(false);
}
}
/**
* 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
*/
protected abstract String getTickMarkLabel(T value);
/**
* Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks
*
*
* @param labelText tick mark label text
* @param rotation The text rotation
* @return size of tick mark label for given value
*/
protected final Dimension2D measureTickMarkLabelSize(String labelText, double rotation) {
measure.setRotate(rotation);
measure.setText(labelText);
Bounds bounds = measure.getBoundsInParent();
return new Dimension2D(bounds.getWidth(), bounds.getHeight());
}
/**
* 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
* @return size of tick mark label for given value
*/
protected final Dimension2D measureTickMarkSize(T value, double rotation) {
return measureTickMarkLabelSize(getTickMarkLabel(value), rotation);
}
/**
* 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
*/
protected Dimension2D measureTickMarkSize(T value, Object range) {
return measureTickMarkSize(value, getEffectiveTickLabelRotation());
}
/**
* 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 side side of this Axis
* @return size of tick mark label for given value
* @see #measureTickMarkSize(Object, Object)
*/
private double measureTickMarkSize(T value, Side side) {
Dimension2D size = measureTickMarkSize(value, getEffectiveTickLabelRotation());
return side.isVertical() ? size.getHeight() : size.getWidth();
}
final double getEffectiveTickLabelRotation() {
return !isAutoRanging() || Double.isNaN(effectiveTickLabelRotation) ? getTickLabelRotation() : effectiveTickLabelRotation;
}
/**
*
* @param rotation NaN for using the tickLabelRotationProperty()
*/
final void setEffectiveTickLabelRotation(double rotation) {
effectiveTickLabelRotation = rotation;
}
// -------------- TICKMARK INNER CLASS -----------------------------------------------------------------------------
/**
* TickMark represents the label text, its associated properties for each tick
* along the Axis.
*
* @param the axis data type
* @since JavaFX 2.0
*/
public static final class TickMark {
/**
* The display text for tick mark
*/
private StringProperty label = new StringPropertyBase() {
@Override protected void invalidated() {
textNode.setText(getValue());
}
@Override
public Object getBean() {
return TickMark.this;
}
@Override
public String getName() {
return "label";
}
};
public final String getLabel() { return label.get(); }
public final void setLabel(String value) { label.set(value); }
public final StringExpression labelProperty() { return label; }
/**
* The value for this tick mark in data units
*/
private ObjectProperty value = new SimpleObjectProperty<>(this, "value");
public final T getValue() { return value.get(); }
public final void setValue(T v) { value.set(v); }
public final ObjectExpression valueProperty() { return value; }
/**
* The display position along the axis from axis origin in display units
*/
private DoubleProperty position = new SimpleDoubleProperty(this, "position");
public final double getPosition() { return position.get(); }
public final void setPosition(double value) { position.set(value); }
public final DoubleExpression positionProperty() { return position; }
Text textNode = new Text();
/** true if tick mark labels should be displayed */
private BooleanProperty textVisible = new BooleanPropertyBase(true) {
@Override protected void invalidated() {
if(!get()) {
textNode.setVisible(false);
}
}
@Override
public Object getBean() {
return TickMark.this;
}
@Override
public String getName() {
return "textVisible";
}
};
/**
* Indicates whether this tick mark label text is displayed or not.
* @return true if tick mark label text is visible and false otherwise
*/
public final boolean isTextVisible() { return textVisible.get(); }
/**
* Specifies whether this tick mark label text is displayed or not.
* @param value true if tick mark label text is visible and false otherwise
*/
public final void setTextVisible(boolean value) { textVisible.set(value); }
/**
* Creates and initializes an instance of TickMark.
*/
public TickMark() {
}
/**
* Returns a string representation of this {@code TickMark} object.
* @return a string representation of this {@code TickMark} object.
*/
@Override public String toString() {
return value.get().toString();
}
}
// -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------
private static class StyleableProperties {
private static final CssMetaData,Side> SIDE =
new CssMetaData<>("-fx-side",
new EnumConverter<>(Side.class)) {
@Override
public boolean isSettable(Axis> n) {
return n.side == null || !n.side.isBound();
}
@SuppressWarnings("unchecked") // sideProperty() is StyleableProperty
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.sideProperty();
}
};
private static final CssMetaData,Number> TICK_LENGTH =
new CssMetaData<>("-fx-tick-length",
SizeConverter.getInstance(), 8.0) {
@Override
public boolean isSettable(Axis> n) {
return n.tickLength == null || !n.tickLength.isBound();
}
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickLengthProperty();
}
};
private static final CssMetaData,Font> TICK_LABEL_FONT =
new FontCssMetaData<>("-fx-tick-label-font",
Font.font("system", 8.0)) {
@Override
public boolean isSettable(Axis> n) {
return n.tickLabelFont == null || !n.tickLabelFont.isBound();
}
@SuppressWarnings("unchecked") // tickLabelFontProperty() is StyleableProperty
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickLabelFontProperty();
}
};
private static final CssMetaData,Paint> TICK_LABEL_FILL =
new CssMetaData<>("-fx-tick-label-fill",
PaintConverter.getInstance(), Color.BLACK) {
@Override
public boolean isSettable(Axis> n) {
return n.tickLabelFill == null | !n.tickLabelFill.isBound();
}
@SuppressWarnings("unchecked") // tickLabelFillProperty() is StyleableProperty
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickLabelFillProperty();
}
};
private static final CssMetaData,Number> TICK_LABEL_TICK_GAP =
new CssMetaData<>("-fx-tick-label-gap",
SizeConverter.getInstance(), 3.0) {
@Override
public boolean isSettable(Axis> n) {
return n.tickLabelGap == null || !n.tickLabelGap.isBound();
}
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickLabelGapProperty();
}
};
private static final CssMetaData,Boolean> TICK_MARK_VISIBLE =
new CssMetaData<>("-fx-tick-mark-visible",
BooleanConverter.getInstance(), Boolean.TRUE) {
@Override
public boolean isSettable(Axis> n) {
return n.tickMarkVisible == null || !n.tickMarkVisible.isBound();
}
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickMarkVisibleProperty();
}
};
private static final CssMetaData,Boolean> TICK_LABELS_VISIBLE =
new CssMetaData<>("-fx-tick-labels-visible",
BooleanConverter.getInstance(), Boolean.TRUE) {
@Override
public boolean isSettable(Axis> n) {
return n.tickLabelsVisible == null || !n.tickLabelsVisible.isBound();
}
@Override
public StyleableProperty getStyleableProperty(Axis> n) {
return (StyleableProperty)n.tickLabelsVisibleProperty();
}
};
private static final List> STYLEABLES;
static {
final List> styleables =
new ArrayList<>(Region.getClassCssMetaData());
styleables.add(SIDE);
styleables.add(TICK_LENGTH);
styleables.add(TICK_LABEL_FONT);
styleables.add(TICK_LABEL_FILL);
styleables.add(TICK_LABEL_TICK_GAP);
styleables.add(TICK_MARK_VISIBLE);
styleables.add(TICK_LABELS_VISIBLE);
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();
}
/** pseudo-class indicating this is a vertical Top side Axis. */
private static final PseudoClass TOP_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("top");
/** pseudo-class indicating this is a vertical Bottom side Axis. */
private static final PseudoClass BOTTOM_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("bottom");
/** pseudo-class indicating this is a vertical Left side Axis. */
private static final PseudoClass LEFT_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("left");
/** pseudo-class indicating this is a vertical Right side Axis. */
private static final PseudoClass RIGHT_PSEUDOCLASS_STATE =
PseudoClass.getPseudoClass("right");
}