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.controlsfx.glyphfont.Glyph; 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.) * <p> * * @author rstein */ public class EditAxis extends ChartPlugin { private static final Logger LOGGER = LoggerFactory.getLogger(EditAxis.class); private static final String FONT_AWESOME = "FontAwesome"; 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<MyPopOver> popUpList = new ArrayList<>(); private final ObjectProperty<Duration> fadeDuration = new SimpleObjectProperty<>(this, "fadeDuration", EditAxis.DEFAULT_ANIMATION_DURATION) { @Override protected void invalidated() { Objects.requireNonNull(get(), new StringBuilder().append("The ").append(getName()).append(" must not be null").toString()); } }; private final ObjectProperty<AxisMode> axisMode = new SimpleObjectProperty<>(this, "axisMode", AxisMode.XY) { @Override protected void invalidated() { Objects.requireNonNull(get(), new StringBuilder().append("The ").append(getName()).append(" must not be null").toString()); } }; /** * 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<AxisMode> 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<Duration> zoomDurationProperty() { return fadeDuration; } 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<Number> tickList = new ArrayList<>(); axis.getTickMarks().forEach(tickMark -> tickList.add(tickMark.getValue())); if (!axis.isLogAxis()) { axis.getMinorTickMarks().forEach(minorTick -> tickList.add(Double.valueOf(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(); final double diff = minTickDistance; if (isIncrease) { property.set(value + diff); } else { property.set(value - diff); } } 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 final 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<String> 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(((DefaultNumericAxis) axis).autoUnitScalingProperty()); unitScaling.textProperty().bindBidirectional(((DefaultNumericAxis) 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(((DefaultNumericAxis) 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(((DefaultNumericAxis) 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 Glyph(EditAxis.FONT_AWESOME, "\uf077")); 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 : !isMin, true); }); final Button decMaxButton = new Button("", new Glyph(EditAxis.FONT_AWESOME, "\uf078")); 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 : !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 Glyph(EditAxis.FONT_AWESOME, "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 Glyph(EditAxis.FONT_AWESOME, "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 = null; private final ChangeListener<Duration> 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; 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); } } }