de.gsi.chart.plugins.EditAxis Maven / Gradle / Ivy
package de.gsi.chart.plugins;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener.Change;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import javafx.util.converter.NumberStringConverter;
import org.controlsfx.control.PopOver;
import org.kordamp.ikonli.javafx.FontIcon;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.gsi.chart.Chart;
import de.gsi.chart.axes.Axis;
import de.gsi.chart.axes.AxisMode;
import de.gsi.chart.axes.spi.AbstractAxis;
import de.gsi.chart.axes.spi.DefaultNumericAxis;
/**
* Allows editing of the chart axes (auto range, minimum/maximum range, etc.)
*
*
* @author rstein
*/
public class EditAxis extends ChartPlugin {
private static final Logger LOGGER = LoggerFactory.getLogger(EditAxis.class);
public static final String STYLE_CLASS_AXIS_EDITOR = "chart-axis-editor";
protected static final int DEFAULT_SHUTDOWN_PERIOD = 5000; // [ms]
protected static final int DEFAULT_UPDATE_PERIOD = 100; // [ms]
protected static final int DEFAULT_PREFERRED_WIDTH = 700; // [pixel]
protected static final int DEFAULT_PREFERRED_HEIGHT = 200; // [pixel]
// private static final String NUMBER_REGEX =
// "[\\x00-\\x20]*[+-]?(((((\\p{Digit}+)(\\.)?((\\p{Digit}+)?)([eE][+-]?(\\p{Digit}+))?)|(\\.((\\p{Digit}+))([eE][+-]?(\\p{Digit}+))?)|(((0[xX](\\p{XDigit}+)(\\.)?)|(0[xX](\\p{XDigit}+)?(\\.)(\\p{XDigit}+)))[pP][+-]?(\\p{Digit}+)))[fFdD]?))[\\x00-\\x20]*";
private static final Duration DEFAULT_ANIMATION_DURATION = Duration.millis(500);
private final BooleanProperty animated = new SimpleBooleanProperty(this, "animated", false);
protected final List popUpList = new ArrayList<>();
private final ObjectProperty fadeDuration = new SimpleObjectProperty<>(this, "fadeDuration",
EditAxis.DEFAULT_ANIMATION_DURATION) {
@Override
protected void invalidated() {
Objects.requireNonNull(get(), "The " + getName() + " must not be null");
}
};
private final ObjectProperty axisMode = new SimpleObjectProperty<>(this, "axisMode", AxisMode.XY) {
@Override
protected void invalidated() {
Objects.requireNonNull(get(), "The " + getName() + " must not be null");
}
};
/**
* Creates a new instance of EditAxis with animation disabled and with {@link #axisModeProperty() editMode}
* initialized to {@link AxisMode#XY}.
*/
public EditAxis() {
this(AxisMode.XY);
}
/**
* Creates a new instance of EditAxis with animation disabled
*
* @param editMode initial value of {@link #axisModeProperty() editMode} property
*/
public EditAxis(final AxisMode editMode) {
this(editMode, false);
}
/**
* Creates a new instance of EditAxis.
*
* @param editMode initial value of {@link #axisModeProperty() axisMode} property
* @param animated initial value of {@link #animatedProperty() animated} property
*/
public EditAxis(final AxisMode editMode, final boolean animated) {
super();
setAxisMode(editMode);
setAnimated(animated);
chartProperty().addListener((obs, oldChart, newChart) -> {
removeMouseEventHandlers(oldChart);
addMouseEventHandlers(newChart);
});
}
/**
* Creates a new instance of EditAxis with {@link #axisModeProperty() editMode} initialized to {@link AxisMode#XY}.
*
* @param animated initial value of {@link #animatedProperty() animated} property
*/
public EditAxis(final boolean animated) {
this(AxisMode.XY, animated);
}
protected void addMouseEventHandlers(final Chart newChart) {
if (newChart == null) {
return;
}
newChart.getAxes().forEach(axis -> popUpList.add(new MyPopOver(axis, axis.getSide().isHorizontal())));
newChart.getAxes().addListener(this::axesChangedHandler);
}
private void axesChangedHandler(@SuppressWarnings("unused") Change extends Axis> ch) { // parameter for EventHandler api
removeMouseEventHandlers(null);
addMouseEventHandlers(getChart());
}
/**
* When {@code true} zooming will be animated. By default it's {@code false}.
*
* @return the animated property
* @see #zoomDurationProperty()
*/
public final BooleanProperty animatedProperty() {
return animated;
}
/**
* The mode defining axis along which the zoom can be performed. By default initialized to {@link AxisMode#XY}.
*
* @return the axis mode property
*/
public final ObjectProperty axisModeProperty() {
return axisMode;
}
/**
* Returns the value of the {@link #axisModeProperty()}.
*
* @return current mode
*/
public final AxisMode getAxisMode() {
return axisModeProperty().get();
}
/**
* Returns the value of the {@link #zoomDurationProperty()}.
*
* @return the current zoom duration
*/
public final Duration getZoomDuration() {
return zoomDurationProperty().get();
}
/**
* Returns the value of the {@link #animatedProperty()}.
*
* @return {@code true} if zoom is animated, {@code false} otherwise
* @see #getZoomDuration()
*/
public final boolean isAnimated() {
return animatedProperty().get();
}
protected void removeMouseEventHandlers(final Chart oldChart) {
popUpList.forEach(MyPopOver::deregisterMouseEvents);
popUpList.clear();
if (oldChart == null) {
return;
}
oldChart.getAxes().removeListener(this::axesChangedHandler);
}
/**
* Sets the value of the {@link #animatedProperty()}.
*
* @param value if {@code true} zoom will be animated
* @see #setZoomDuration(Duration)
*/
public final void setAnimated(final boolean value) {
animatedProperty().set(value);
}
/**
* Sets the value of the {@link #axisModeProperty()}.
*
* @param mode the mode to be used
*/
public final void setAxisMode(final AxisMode mode) {
axisModeProperty().set(mode);
}
/**
* Sets the value of the {@link #zoomDurationProperty()}.
*
* @param duration duration of the zoom
*/
public final void setZoomDuration(final Duration duration) {
zoomDurationProperty().set(duration);
}
/**
* Duration of the animated fade (in and out). Used only when {@link #animatedProperty()} is set to {@code true}. By
* default initialized to 500ms.
*
* @return the zoom duration property
*/
public final ObjectProperty zoomDurationProperty() {
return fadeDuration;
}
protected static class AxisEditor extends BorderPane {
AxisEditor(final Axis axis, final boolean isHorizontal) {
super();
setTop(getLabelEditor(axis, isHorizontal));
final Pane box = isHorizontal ? new HBox() : new VBox();
setCenter(box);
if (isHorizontal) {
box.setPrefWidth(EditAxis.DEFAULT_PREFERRED_WIDTH);
} else {
box.setPrefHeight(EditAxis.DEFAULT_PREFERRED_HEIGHT);
}
box.getChildren().add(getMinMaxButtons(axis, isHorizontal, true));
// add lower-bound text field
box.getChildren().add(getBoundField(axis, isHorizontal));
box.getChildren().add(createSpacer());
box.getChildren().add(getLogCheckBoxes(axis));
box.getChildren().add(getRangeChangeButtons(axis, isHorizontal));
box.getChildren().add(getAutoRangeCheckBoxes(axis));
box.getChildren().add(createSpacer());
// add upper-bound text field
box.getChildren().add(getBoundField(axis, !isHorizontal));
box.getChildren().add(getMinMaxButtons(axis, isHorizontal, false));
}
protected void changeAxisRange(final Axis axis, final boolean isIncrease) {
final double width = Math.abs(axis.getMax() - axis.getMin());
// TODO: check for linear and logarithmic axis
changeAxisRangeLinearScale(width, axis.minProperty(), !isIncrease);
changeAxisRangeLinearScale(width, axis.maxProperty(), isIncrease);
}
private void changeAxisRangeLimit(final Axis axis, final boolean isHorizontal, final boolean isIncrease) {
final boolean isInverted = axis.isInvertedAxis();
DoubleProperty prop;
if (isHorizontal) {
prop = isInverted ? axis.maxProperty() : axis.minProperty();
} else {
prop = isInverted ? axis.minProperty() : axis.maxProperty();
}
double minTickDistance = Double.MAX_VALUE;
final List tickList = new ArrayList<>();
axis.getTickMarks().forEach(tickMark -> tickList.add(tickMark.getValue()));
if (!axis.isLogAxis()) {
axis.getMinorTickMarks().forEach(minorTick -> tickList.add(minorTick.getPosition()));
}
for (final Number check1 : tickList) {
for (final Number check2 : tickList) {
minTickDistance = Math.min(Math.abs(check1.doubleValue() - check2.doubleValue()), minTickDistance);
}
}
if (axis.isLogAxis()) {
minTickDistance *= 0.1;
}
if ((minTickDistance == Double.MAX_VALUE) || (minTickDistance <= 0)) {
// default fall-back in case no minor tick have been defined for
// the axis
minTickDistance = 0.05 * Math.abs(axis.getMax() - axis.getMin());
}
if (axis.getTickUnit() > 0) {
minTickDistance = axis.getTickUnit();
}
// TODO: check for linear and logarithmic axis
changeAxisRangeLinearScale(minTickDistance, prop, isIncrease);
if (axis instanceof AbstractAxis) {
// ((AbstractAxis) axis).recomputeTickMarks();
axis.setTickUnit(((AbstractAxis) axis).computePreferredTickUnit(axis.getLength()));
}
}
protected void changeAxisRangeLinearScale(final double minTickDistance, final DoubleProperty property,
final boolean isIncrease) {
final double value = property.doubleValue();
if (isIncrease) {
property.set(value + minTickDistance);
} else {
property.set(value - minTickDistance);
}
}
private Node createSpacer() {
final Region spacer = new Region();
// Make it always grow or shrink according to the available space
VBox.setVgrow(spacer, Priority.ALWAYS);
HBox.setHgrow(spacer, Priority.ALWAYS);
return spacer;
}
private Pane getAutoRangeCheckBoxes(final Axis axis) {
final Pane boxMax = new VBox();
VBox.setVgrow(boxMax, Priority.ALWAYS);
final CheckBox autoRanging = new CheckBox("auto ranging");
HBox.setHgrow(autoRanging, Priority.ALWAYS);
VBox.setVgrow(autoRanging, Priority.ALWAYS);
autoRanging.setMaxWidth(Double.MAX_VALUE);
autoRanging.setSelected(axis.isAutoRanging());
autoRanging.selectedProperty().bindBidirectional(axis.autoRangingProperty());
boxMax.getChildren().add(autoRanging);
final CheckBox autoGrow = new CheckBox("auto grow");
HBox.setHgrow(autoGrow, Priority.ALWAYS);
VBox.setVgrow(autoGrow, Priority.ALWAYS);
autoGrow.setMaxWidth(Double.MAX_VALUE);
autoGrow.setSelected(axis.isAutoGrowRanging());
autoGrow.selectedProperty().bindBidirectional(axis.autoGrowRangingProperty());
boxMax.getChildren().add(autoGrow);
return boxMax;
}
private TextField getBoundField(final Axis axis, final boolean isLowerBound) {
final TextField textField = new TextField();
// ValidationSupport has a slow memory leak
// final ValidationSupport support = new ValidationSupport();
// final Validator validator = (final Control control, final
// String value) -> {
// boolean condition = value == null ? true :
// !value.matches(NUMBER_REGEX);
//
// // additional check in case of logarithmic axis
// if (!condition && axis.isLogAxis() && Double.parseDouble(value)
// <= 0) {
// condition = true;
// }
// // change text colour depending on validity as a number
// textField.setStyle(condition ? "-fx-text-inner-color: red;" :
// "-fx-text-inner-color: black;");
// return ValidationResult.fromMessageIf(control, "not a number",
// Severity.ERROR, condition);
// };
// support.registerValidator(textField, true, validator);
final Runnable lambda = () -> {
final double value;
final boolean isInverted = axis.isInvertedAxis();
if (isLowerBound) {
value = isInverted ? axis.getMax() : axis.getMin();
} else {
value = isInverted ? axis.getMin() : axis.getMax();
}
textField.setText(Double.toString(value));
};
axis.invertAxisProperty().addListener((ch, o, n) -> lambda.run());
axis.minProperty().addListener((ch, o, n) -> lambda.run());
axis.maxProperty().addListener((ch, o, n) -> lambda.run());
// force the field to be numeric only
textField.textProperty().addListener((observable, oldValue, newValue) -> {
if ((newValue != null) && !newValue.matches("\\d*")) {
final double val;
try {
val = Double.parseDouble(newValue);
} catch (NullPointerException | NumberFormatException e) {
// not a parsable number
textField.setText(oldValue);
return;
}
if (axis.isLogAxis() && (val <= 0)) {
textField.setText(oldValue);
return;
}
textField.setText(Double.toString(val));
}
});
textField.setOnKeyPressed(ke -> {
if (ke.getCode().equals(KeyCode.ENTER)) {
final double presentValue = Double.parseDouble(textField.getText());
if (isLowerBound && !axis.isInvertedAxis()) {
axis.setMin(presentValue);
} else {
axis.setMax(presentValue);
}
axis.setAutoRanging(false);
if (axis instanceof AbstractAxis) {
// ((AbstractAxis) axis).recomputeTickMarks();
axis.setTickUnit(((AbstractAxis) axis).computePreferredTickUnit(axis.getLength()));
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("recompute axis tick unit to {}",
((AbstractAxis) axis).computePreferredTickUnit(axis.getLength()));
}
}
}
});
HBox.setHgrow(textField, Priority.ALWAYS);
VBox.setVgrow(textField, Priority.ALWAYS);
return textField;
}
/**
* Creates the header for the Axis Editor popup, allowing to configure axis label and unit
*
* @param axis The axis to be edited
* @return pane containing label, label editor and unit editor
*/
private Node getLabelEditor(final Axis axis, final boolean isHorizontal) {
final GridPane header = new GridPane();
header.setAlignment(Pos.BASELINE_LEFT);
final TextField axisLabelTextField = new TextField(axis.getName());
axisLabelTextField.textProperty().bindBidirectional(axis.nameProperty());
header.addRow(0, new Label(" axis label: "), axisLabelTextField);
final TextField axisUnitTextField = new TextField(axis.getUnit());
axisUnitTextField.setPrefWidth(50.0);
axisUnitTextField.textProperty().bindBidirectional(axis.unitProperty());
header.addRow(isHorizontal ? 0 : 1, new Label(" unit: "), axisUnitTextField);
final TextField unitScaling = new TextField();
unitScaling.setPrefWidth(80.0);
final CheckBox autoUnitScaling = new CheckBox(" auto");
if (axis instanceof DefaultNumericAxis) {
autoUnitScaling.selectedProperty()
.bindBidirectional(axis.autoUnitScalingProperty());
unitScaling.textProperty().bindBidirectional(axis.unitScalingProperty(),
new NumberStringConverter(new DecimalFormat("0.0####E0")));
unitScaling.disableProperty().bind(autoUnitScaling.selectedProperty());
} else {
// TODO: consider adding an interface on whether
// autoUnitScaling is editable
autoUnitScaling.setDisable(true);
unitScaling.setDisable(true);
}
final HBox unitScalingBox = new HBox(unitScaling, autoUnitScaling);
unitScalingBox.setAlignment(Pos.BASELINE_LEFT);
header.addRow(isHorizontal ? 0 : 2, new Label(" unit scale:"), unitScalingBox);
return header;
}
private Pane getLogCheckBoxes(final Axis axis) {
final Pane boxMax = new VBox();
VBox.setVgrow(boxMax, Priority.ALWAYS);
final CheckBox logAxis = new CheckBox("log axis");
HBox.setHgrow(logAxis, Priority.ALWAYS);
VBox.setVgrow(logAxis, Priority.ALWAYS);
logAxis.setMaxWidth(Double.MAX_VALUE);
logAxis.setSelected(axis.isLogAxis());
boxMax.getChildren().add(logAxis);
if (axis instanceof DefaultNumericAxis) {
logAxis.selectedProperty().bindBidirectional(((DefaultNumericAxis) axis).logAxisProperty());
} else {
// TODO: consider adding an interface on whether log/non-log
// is editable
logAxis.setDisable(true);
}
final CheckBox invertedAxis = new CheckBox("inverted");
HBox.setHgrow(invertedAxis, Priority.ALWAYS);
VBox.setVgrow(invertedAxis, Priority.ALWAYS);
invertedAxis.setMaxWidth(Double.MAX_VALUE);
invertedAxis.setSelected(axis.isInvertedAxis());
boxMax.getChildren().add(invertedAxis);
if (axis instanceof DefaultNumericAxis) {
invertedAxis.selectedProperty().bindBidirectional(axis.invertAxisProperty());
} else {
// TODO: consider adding an interface on whether
// invertedAxis is editable
invertedAxis.setDisable(true);
}
final CheckBox timeAxis = new CheckBox("time axis");
HBox.setHgrow(timeAxis, Priority.ALWAYS);
VBox.setVgrow(timeAxis, Priority.ALWAYS);
timeAxis.setMaxWidth(Double.MAX_VALUE);
timeAxis.setSelected(axis.isTimeAxis());
boxMax.getChildren().add(timeAxis);
if (axis instanceof DefaultNumericAxis) {
timeAxis.selectedProperty().bindBidirectional(axis.timeAxisProperty());
} else {
// TODO: consider adding an interface on whether
// timeAxis is editable
timeAxis.setDisable(true);
}
return boxMax;
}
private Pane getMinMaxButtons(final Axis axis, final boolean isHorizontal, final boolean isMin) {
final Button incMaxButton = new Button("", new FontIcon("fa-chevron-up"));
incMaxButton.setMaxWidth(Double.MAX_VALUE);
VBox.setVgrow(incMaxButton, Priority.ALWAYS);
HBox.setHgrow(incMaxButton, Priority.ALWAYS);
incMaxButton.setOnAction(evt -> {
axis.setAutoRanging(false);
changeAxisRangeLimit(axis, isHorizontal == isMin, true);
});
final Button decMaxButton = new Button("", new FontIcon("fa-chevron-down"));
decMaxButton.setMaxWidth(Double.MAX_VALUE);
VBox.setVgrow(decMaxButton, Priority.ALWAYS);
HBox.setHgrow(decMaxButton, Priority.ALWAYS);
decMaxButton.setOnAction(evt -> {
axis.setAutoRanging(false);
changeAxisRangeLimit(axis, isHorizontal == isMin, false);
});
final Pane box = isHorizontal ? new VBox() : new HBox();
box.getChildren().addAll(incMaxButton, decMaxButton);
return box;
}
private Pane getRangeChangeButtons(final Axis axis, final boolean isHorizontal) {
final Button incMaxButton = new Button("", new FontIcon("fa-expand"));
incMaxButton.setMaxWidth(Double.MAX_VALUE);
VBox.setVgrow(incMaxButton, Priority.NEVER);
HBox.setHgrow(incMaxButton, Priority.NEVER);
incMaxButton.setOnAction(evt -> {
axis.setAutoRanging(false);
changeAxisRange(axis, true);
});
final Button decMaxButton = new Button("", new FontIcon("fa-compress"));
decMaxButton.setMaxWidth(Double.MAX_VALUE);
VBox.setVgrow(decMaxButton, Priority.NEVER);
HBox.setHgrow(decMaxButton, Priority.NEVER);
decMaxButton.setOnAction(evt -> {
axis.setAutoRanging(false);
changeAxisRange(axis, false);
});
final Pane boxMax = isHorizontal ? new VBox() : new HBox();
boxMax.getChildren().addAll(incMaxButton, decMaxButton);
return boxMax;
}
}
private class MyPopOver extends PopOver {
private long popOverShowStartTime;
private boolean isMouseInPopOver;
private Axis axis;
private final ChangeListener fadeDurationListener = (ch, o, n) -> {
super.fadeInDurationProperty().set(n.multiply(2.0));
super.fadeOutDurationProperty().set(n);
};
private final EventHandler super MouseEvent> axisClickEventHandler = evt -> {
if (evt.getButton() == MouseButton.SECONDARY) {
final double x = evt.getScreenX();
final double y = evt.getScreenY();
if (axis != null) {
show((Node) axis, x, y);
}
}
};
MyPopOver(final Axis axis, final boolean isHorizontal) {
super(new AxisEditor(axis, isHorizontal));
this.axis = axis;
popOverShowStartTime = 0;
getStyleClass().add(STYLE_CLASS_AXIS_EDITOR);
super.setAutoHide(true);
super.animatedProperty().bind(EditAxis.this.animatedProperty());
EditAxis.this.zoomDurationProperty().addListener(fadeDurationListener);
super.fadeInDurationProperty().set(EditAxis.this.getZoomDuration().multiply(2.0));
super.fadeOutDurationProperty().set(EditAxis.this.getZoomDuration());
setFadeInDuration(Duration.millis(1000));
setFadeOutDuration(Duration.millis(500));
switch (axis.getSide()) {
case TOP:
setArrowLocation(ArrowLocation.TOP_CENTER);
break;
case LEFT:
setArrowLocation(ArrowLocation.LEFT_CENTER);
break;
case RIGHT:
setArrowLocation(ArrowLocation.RIGHT_CENTER);
break;
case BOTTOM:
default:
setArrowLocation(ArrowLocation.BOTTOM_CENTER);
break;
}
setOpacity(0.0);
getRoot().setBackground(Background.EMPTY);
// getRoot().setStyle("-fx-background-color: rgba(0, 255, 0, 1);");
getScene().getStylesheets().add("plugin/editaxis.css");
getStyleClass().add("axis-editor-view-pane");
final Timeline checkMouseInsidePopUp = new Timeline(
new KeyFrame(Duration.millis(EditAxis.DEFAULT_UPDATE_PERIOD), event -> {
if (!isShowing()) {
return;
}
final long now = System.currentTimeMillis();
if (isMouseInPopOver) {
popOverShowStartTime = System.currentTimeMillis();
}
if (Math.abs(now - popOverShowStartTime) > EditAxis.DEFAULT_SHUTDOWN_PERIOD) {
hide();
}
}));
checkMouseInsidePopUp.play();
registerMouseEvents();
}
public void deregisterMouseEvents() {
((Node) axis).removeEventHandler(MouseEvent.MOUSE_CLICKED, axisClickEventHandler);
EditAxis.this.zoomDurationProperty().removeListener(fadeDurationListener);
super.animatedProperty().unbind();
}
public final void registerMouseEvents() {
setOnShowing(evt -> popOverShowStartTime = System.currentTimeMillis());
getContentNode().setOnMouseEntered(mevt -> isMouseInPopOver = true);
getContentNode().setOnMouseExited(mevt -> isMouseInPopOver = false);
((Node) axis).setOnMouseClicked(axisClickEventHandler);
}
}
}