This discussion is archived
13 Replies Latest reply: Aug 24, 2013 8:21 AM by ciruman RSS

Display Tooltips on charts values?

csh Journeyer
Currently Being Moderated
Hi,

is it possible to display Tooltips on the values of a Chart?

E.g. in a PieChart I want to display the actual amount and the percentage of a pie piece.

In a XYChart I want to display a tooltip on each dot in the chart to display the exact XY value.

I've found this thread, which helped me:
CategoryAxis Tooltips

But, this method feels like a non-public workaround.

And furthermore I can't get the value out of the StackPane (which represents the value in the XYchart):
for (Node mark : lineChart.lookupAll(".chart-line-symbol")) {
   Tooltip.install(mark, new Tooltip("Text"));
}
  • 1. Re: Display Tooltips on charts values?
    csh Journeyer
    Currently Being Moderated
    Ok, I've got it working now, with this:
    final LineChart<Date, Number> lineChart = new LineChart<Date, Number>(xAxis, yAxis) {
                @Override
                protected void layoutChildren() {
                    super.layoutChildren();
                    for (Node mark : lookupAll("StackPane.chart-line-symbol")) {
                        Bounds bounds = mark.getBoundsInParent();
                        Date date = xAxis.getValueForDisplay(bounds.getMinX() + Math.round(bounds.getMaxX() - bounds.getMinX()) / 2);
                        DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
    
                        String dateFormatted = dateFormat.format(date);
                        Tooltip.install(mark, new Tooltip(dateFormatted));
                    }
                }
            };
    Is this the best available method?
  • 2. Re: Display Tooltips on charts values?
    csh Journeyer
    Currently Being Moderated
    nvm, I got it working now with this, which looks like a good solution:
    final LineChart<Date, Number> lineChart = new LineChart<Date, Number>(xAxis, yAxis) {
                @Override
                protected void layoutPlotChildren() {
                    super.layoutPlotChildren();
                    for (Node mark : getPlotChildren()) {
                        if (mark instanceof StackPane) {
                            Bounds bounds = mark.getBoundsInParent();
                            double posX = bounds.getMinX() + (bounds.getMaxX() - bounds.getMinX()) / 2.0;
                            double posY = bounds.getMinY() + (bounds.getMaxY() - bounds.getMinY()) / 2.0;
                            Number number = getYAxis().getValueForDisplay(posY);
                            Date date = getXAxis().getValueForDisplay(posX);
                            // Set Tooltip
                        }
                    }
                }
            };
  • 3. Re: Display Tooltips on charts values?
    jsmith Guru
    Currently Being Moderated
    As you have found, there are lots of ways to do this.

    There are some more in this thread:
    How to remove symbols from an AreaChart in JavaFX "Thread: How to remove symbols from an AreaChart in JavaFX"
    The thread is about hiding symbols, the problem is essentially similar - how to select the datapoint nodes of the chart and customize them.

    Also note that XYChart.Data has a node property on which you could install a tooltip.
    http://docs.oracle.com/javafx/2/api/javafx/scene/chart/XYChart.Data.html#nodeProperty
    The node to display for this data item. You can either create your own node and set it on the data item before you add the item to the chart. Otherwise the chart will create a node for you that has the default representation for the chart type. This node will be set as soon as the data is added to the chart. You can then get it to add mouse listeners etc. Charts will do their best to position and size the node appropriately, for example on a Line or Scatter chart this node will be positioned centered on the data values position. For a bar chart this is positioned and resized as the bar for this data item.
  • 4. Re: Display Tooltips on charts values?
    Mat Morton Newbie
    Currently Being Moderated
    Interesting code, thanks.

    Also, I noticed you are using a Date object with the XYChart. What type of Axis are you using that takes a date?
  • 5. Re: Display Tooltips on charts values?
    csh Journeyer
    Currently Being Moderated
    I've written a DateAxis myself. Maybe I will blog about it.
  • 6. Re: Display Tooltips on charts values?
    Mat Morton Newbie
    Currently Being Moderated
    csh, if you don't mind sharing your DateAxis I would enjoy taking a look.
  • 7. Re: Display Tooltips on charts values?
    James_D Guru
    Currently Being Moderated
    Yes, me too: that would be great if you have a chance to write it up.
  • 8. Re: Display Tooltips on charts values?
    Mat Morton Newbie
    Currently Being Moderated
    Using the Node property of the XYChart.Data was a great idea. I ended up leveraging it to apply a tooltip.

    This way you get the exact values. I encountered rounding errors when pulling the display values from the axes.
      private static XYChart.Data<String, Number> datum( String x, long y )
      {
          final XYChart.Data<String, Number> data = new XYChart.Data<String, Number>( x, Long.valueOf( y ) );
          data.nodeProperty().addListener(new ChangeListener<Node>()
          {
            @Override
            public void changed(ObservableValue<? extends Node> arg0, Node arg1,
                Node arg2)
            {
              Tooltip t = new Tooltip( data.getYValue().toString() + '\n' + data.getXValue());
              Tooltip.install(arg2, t);
              data.nodeProperty().removeListener(this);
            }
          });
          return data;
      }
  • 9. Re: Display Tooltips on charts values?
    csh Journeyer
    Currently Being Moderated
    Thanks for the suggestion with the node. I encountered rounding errors, too.

    Here is my DateAxis. I've struggled quite a bit with the animation stuff and I am not sure, if I should use ChartLayoutAnimator since it is in com.sun package. But actually it only sets up an animation timer and calls requestAxisLayout().
    The most complicated parts were the getDisplayPosition, getValueForDisplay and calculateTickValues methods.

    This axis is hardly tested well, only for some few test data and only for horizontal axis.
    Also I am not quite sure, if I implemented the range-methods correctly.

    I'd appreciate any input or improvements, especially with the animation stuff. An unsolved problem is, that, if you have two dates, let's say "today" and "tomorrow" and add a third date sometime in the future to the data list, it animates "somehow", but it doesn't really relayout the axis until I resize the window. I tried requestAxisLayout() and any other layout related method, but no success.
    import com.sun.javafx.charts.ChartLayoutAnimator;
    import javafx.animation.AnimationTimer;
    import javafx.animation.KeyFrame;
    import javafx.animation.KeyValue;
    import javafx.animation.Timeline;
    import javafx.beans.property.LongProperty;
    import javafx.beans.property.ObjectProperty;
    import javafx.beans.property.ObjectPropertyBase;
    import javafx.beans.property.SimpleLongProperty;
    import javafx.event.ActionEvent;
    import javafx.event.EventHandler;
    import javafx.geometry.Side;
    import javafx.scene.chart.Axis;
    import javafx.util.Duration;
    import javafx.util.StringConverter;
    
    import java.text.DateFormat;
    import java.text.SimpleDateFormat;
    import java.util.*;
    
    /**
     * @author Christian Schudt
     */
    public class DateAxis extends Axis<Date> {
    
        private static final int[] intervals = new int[]{
                Calendar.YEAR,
                Calendar.MONTH,
                Calendar.WEEK_OF_YEAR,
                Calendar.DATE,
                Calendar.HOUR,
                Calendar.MINUTE,
                Calendar.SECOND,
                Calendar.MILLISECOND
        };
    
        private Date minDate, maxDate;
    
        protected final LongProperty currentLowerBound = new SimpleLongProperty(this, "currentLowerBound");
        protected final LongProperty currentUpperBound = new SimpleLongProperty(this, "currentUpperBound");
    
        public DateAxis() {
        }
    
        public DateAxis(Date lowerBound, Date upperBound) {
            setAutoRanging(false);
            setLowerBound(lowerBound);
            setUpperBound(upperBound);
        }
    
        @Override
        protected Object autoRange(double length) {
            if (isAutoRanging()) {
                return new Object[]{minDate, maxDate};
            } else {
                if (lowerBound.get() == null || upperBound.get() == null) {
                    throw new IllegalArgumentException("If autoRanging is false, a lower and upper bound must be set.");
                }
                return getRange();
            }
        }
    
        @Override
        public void invalidateRange(List<Date> list) {
            super.invalidateRange(list);
    
            Collections.sort(list);
            if (list.size() == 0) {
                minDate = maxDate = new Date();
            } else if (list.size() == 1) {
                minDate = maxDate = list.get(0);
            } else if (list.size() > 1) {
                minDate = list.get(0);
                maxDate = list.get(list.size() - 1);
            }
        }
    
        private ChartLayoutAnimator animator = new ChartLayoutAnimator(this);
        private Object currentAnimationID;
    
        @Override
        protected void setRange(Object range, boolean animating) {
            Object[] r = (Object[]) range;
            Date oldLowerBound = getLowerBound();
            Date oldUpperBound = getUpperBound();
            setLowerBound((Date) r[0]);
            setUpperBound((Date) r[1]);
    
            if (animating) {
                final Timeline timeline = new Timeline();
                timeline.setAutoReverse(false);
                timeline.setCycleCount(1);
                final AnimationTimer timer = new AnimationTimer() {
                    @Override
                    public void handle(long l) {
                        requestAxisLayout();
                    }
                };
                timer.start();
    
                timeline.setOnFinished(new EventHandler<ActionEvent>() {
                    @Override
                    public void handle(ActionEvent actionEvent) {
                        timer.stop();
                        requestAxisLayout();
                    }
                });
                KeyFrame kf = new KeyFrame(Duration.ZERO,
    
                        new KeyValue(currentLowerBound, oldLowerBound.getTime()),
    
                        new KeyValue(currentUpperBound, oldUpperBound.getTime())
    
                );
                KeyValue keyValue = new KeyValue(currentLowerBound, ((Date) r[0]).getTime());
                KeyValue keyValue2 = new KeyValue(currentUpperBound, ((Date) r[1]).getTime());
    
    //            animator.stop(currentAnimationID);
    //            currentAnimationID = animator.animate(new KeyFrame(Duration.seconds(3), keyValue, keyValue2));
    
                timeline.getKeyFrames().addAll(new KeyFrame(Duration.millis(3000), keyValue, keyValue2));
                timeline.play();
            } else {
                currentLowerBound.set(getLowerBound().getTime());
                currentUpperBound.set(getUpperBound().getTime());
            }
        }
    
        @Override
        protected Object getRange() {
            return new Object[]{getLowerBound(), getUpperBound()};
        }
    
        @Override
        public double getZeroPosition() {
            return 0;
        }
    
        @Override
        public double getDisplayPosition(Date date) {
            final double length = (Side.TOP.equals(getSide()) || Side.BOTTOM.equals(getSide())) ? getWidth() : getHeight();
    
            // Get the difference between the max and min date.
            double diff = currentUpperBound.get() - currentLowerBound.get();
    
            // Get the actual range of the visible area.
            // The minimal date should start at the zero position, that's why we subtract it.
            double range = length - getZeroPosition();
    
            // Then get the difference from the actual date to the min date and divide it by the total difference.
            // We get a value between 0 and 1, if the date is within the min and max date.
            double d = (date.getTime() - currentLowerBound.get()) / diff;
            //System.out.println(d * range + getZeroPosition());
            // Multiply this percent value with the range and add the zero offset.
    
            return d * range + getZeroPosition();
        }
    
        @Override
        public Date getValueForDisplay(double v) {
            final double length = (Side.TOP.equals(getSide()) || Side.BOTTOM.equals(getSide())) ? getWidth() : getHeight();
    
            // Get the difference between the max and min date.
            double diff = currentUpperBound.get() - currentLowerBound.get();
    
            // Get the actual range of the visible area.
            // The minimal date should start at the zero position, that's why we subtract it.
            double range = length - getZeroPosition();
    
            // If 0 is the minDate and 1 is the maxDate this is the value between 0 and 1 representing the date.
            double factor = (v - getZeroPosition()) / range;
    
            // To get the actual date, multiply the factor with the difference and add the minDate.
            return new Date((long) (factor * diff + currentLowerBound.get()));
        }
    
    
        @Override
        public boolean isValueOnAxis(Date date) {
            return getDisplayPosition(date) > 0 && date.getTime() < currentUpperBound.get();
        }
    
        @Override
        public double toNumericValue(Date date) {
            return date.getTime();
        }
    
        @Override
        public Date toRealValue(double v) {
            return new Date((long) v);
        }
    
        @Override
        protected List<Date> calculateTickValues(double v, Object o) {
    
            List<Date> dateList = new ArrayList<Date>();
            Calendar calendar = Calendar.getInstance();
    
            // The preferred gap which should be between two tick marks.
            double averageTickGap = 100;
            double averageTicks = v / averageTickGap;
    
            List<Date> previousDateList = new ArrayList<Date>();
    
            int previousInterval = intervals[0];
    
            // Starting with the greatest interval, add one of each calendar unit.
            for (int interval : intervals) {
                // Reset the calendar.
                calendar.setTime(new Date(currentLowerBound.get()));
                // Clear the list.
                dateList.clear();
                previousDateList.clear();
                actualInterval = interval;
                if (upperBound.get() != null) {
                    // Loop as long we exceeded the upper bound.
                    while (calendar.getTime().getTime() < currentUpperBound.get()) {
                        dateList.add(calendar.getTime());
                        calendar.add(interval, 1);
                    }
                    // Then check the size of the list. If it is greater than the amount of ticks, take that list.
                    if (dateList.size() > averageTicks) {
                        calendar.setTime(new Date(currentLowerBound.get()));
                        // Recheck if the previous interval is better suited.
                        while (calendar.getTime().getTime() < currentUpperBound.get()) {
                            previousDateList.add(calendar.getTime());
                            calendar.add(previousInterval, 1);
                        }
                        break;
                    }
                }
                previousInterval = interval;
            }
            if (previousDateList.size() - averageTicks > averageTicks - dateList.size()) {
                dateList = previousDateList;
                actualInterval = previousInterval;
            }
    
            return dateList;
        }
    
        private int actualInterval = 0;
    
        @Override
        protected String getTickMarkLabel(Date date) {
            StringConverter<Date> converter = getTickLabelFormatter();
            if (converter != null) {
                return converter.toString(date);
            }
            DateFormat dateFormat;
            switch (actualInterval) {
                case Calendar.YEAR:
                    dateFormat = new SimpleDateFormat("yyyy");
                    break;
                case Calendar.MONTH:
                    dateFormat = new SimpleDateFormat("MM/yyyy");
                    break;
                case Calendar.DATE:
                case Calendar.WEEK_OF_YEAR:
                default:
                    dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM);
                    break;
                case Calendar.HOUR:
                case Calendar.MINUTE:
                    dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
                    break;
                case Calendar.SECOND:
                    dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM);
                    break;
                case Calendar.MILLISECOND:
                    dateFormat = DateFormat.getDateInstance(DateFormat.FULL);
                    break;
            }
    
            return dateFormat.format(date);
        }
    
        private ObjectProperty<Date> lowerBound = new ObjectPropertyBase<Date>() {
            @Override
            protected void invalidated() {
                if (!isAutoRanging()) {
                    invalidateRange();
                    requestAxisLayout();
                }
            }
    
            @Override
            public Object getBean() {
                return DateAxis.this;
            }
    
            @Override
            public String getName() {
                return "lowerBound";
            }
        };
    
        public final ObjectProperty<Date> lowerBoundProperty() {
            return lowerBound;
        }
    
        public final void setLowerBound(Date date) {
            lowerBound.set(date);
        }
    
        public final Date getLowerBound() {
            return lowerBound.get();
        }
    
    
        private ObjectProperty<Date> upperBound = new ObjectPropertyBase<Date>() {
            @Override
            protected void invalidated() {
                if (!isAutoRanging()) {
                    invalidateRange();
                    requestAxisLayout();
                }
            }
    
            @Override
            public Object getBean() {
                return DateAxis.this;
            }
    
            @Override
            public String getName() {
                return "upperBound";  //To change body of implemented methods use File | Settings | File Templates.
            }
        };
    
        public final ObjectProperty<Date> upperBoundProperty() {
            return upperBound;
        }
    
        public final void setUpperBound(Date date) {
            upperBound.set(date);
        }
    
        public final Date getUpperBound() {
            return upperBound.get();
        }
    
        private final ObjectProperty<StringConverter<Date>> tickLabelFormatter = new ObjectPropertyBase<StringConverter<Date>>() {
            @Override
            protected void invalidated() {
                if (!isAutoRanging()) {
                    invalidateRange();
                    requestAxisLayout();
                }
            }
    
            @Override
            public Object getBean() {
                return DateAxis.this;
            }
    
            @Override
            public String getName() {
                return "tickLabelFormatter";
            }
        };
    
        public final StringConverter<Date> getTickLabelFormatter() {
            return tickLabelFormatter.getValue();
        }
    
        public final void setTickLabelFormatter(StringConverter<Date> value) {
            tickLabelFormatter.setValue(value);
        }
    
        public final ObjectProperty<StringConverter<Date>> tickLabelFormatterProperty() {
            return tickLabelFormatter;
        }
    }
    Here's some test application, click the button, to see the above mentioned problem:
    import javafx.application.Application;
    import javafx.application.Platform;
    import javafx.event.ActionEvent;
    import javafx.event.EventHandler;
    import javafx.scene.Scene;
    import javafx.scene.chart.Chart;
    import javafx.scene.chart.LineChart;
    import javafx.scene.chart.NumberAxis;
    import javafx.scene.chart.XYChart;
    import javafx.scene.control.Button;
    import javafx.scene.layout.Priority;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    import org.testng.Assert;
    
    import java.util.Date;
    import java.util.GregorianCalendar;
    
    
    public class DateAxisTest extends Application {
    
        private void init(Stage primaryStage) {
            VBox root = new VBox();
            final Chart chart = createChart();
    
            VBox.setVgrow(chart, Priority.ALWAYS);
            primaryStage.setScene(new Scene(root));
            xAxis.setAutoRanging(true);
            xAxis.setAnimated(true);
            //xAxis.setLowerBound(new GregorianCalendar(2008, 0, 1).getTime());
            //xAxis.setUpperBound(new GregorianCalendar(2018, 0, 1).getTime());
            Button btnSetMaxDate = new Button("Click me");
            btnSetMaxDate.setOnAction(new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent actionEvent) {
                    series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(2012, 1, 1).getTime(), 60d));
                    //series2.getData().add(new XYChart.Data<Number, Number>(15, 160d));
    
                    new Thread(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                Thread.sleep(4000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            Platform.runLater(new Runnable() {
                                @Override
                                public void run() {
                                    chart.requestLayout();
                                }
                            });
                        }
                    }).start();
                }
            });
    
            root.getChildren().addAll(chart, btnSetMaxDate);
        }
    
        final DateAxis xAxis = new DateAxis();
        XYChart.Series<Date, Number> series;
    
        protected LineChart<Date, Number> createChart() {
    
            final NumberAxis yAxis = new NumberAxis();
            yAxis.setAnimated(true);
    
            final LineChart<Date, Number> lc = new LineChart<Date, Number>(xAxis, yAxis);
            //lc.setAnimated(false);
            // setup chart
            lc.setTitle("Basic LineChart");
            xAxis.setLabel("X Axis");
            //xAxis.setTickLabelRotation(-90);
            yAxis.setLabel("Y Axis");
            // add starting data
            series = new XYChart.Series<Date, Number>();
            series.setName("Data Series 1");
            series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(1970, 3, 1, 12, 2, 1).getTime(), 50d));
            series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(1972, 3, 1, 23, 3, 1).getTime(), 80d));
            /*series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(2011, 3, 3).getTime(), 90d));
            series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(2011, 3, 4).getTime(), 30d));
            series.getData().add(new XYChart.Data<Date, Number>(new GregorianCalendar(2011, 3, 6).getTime(), 122d));
            */
            lc.getData().add(series);
            return lc;
        }
    
        @Override
        public void start(Stage primaryStage) throws Exception {
            init(primaryStage);
            primaryStage.show();
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
  • 10. Re: Display Tooltips on charts values?
    ciruman Newbie
    Currently Being Moderated

    Hi Christian,

     

    I was looking for a DateAxis implementation and I didn't find anything, just your solution and I really like it. I would like to use and push forward your implementation (fix the animation problem, add localization and any other problem or improvement). Could you provide it somewhere(bitBucket ) where I could contribute.

     

    Thanks.

  • 11. Re: Display Tooltips on charts values?
    csh Journeyer
    Currently Being Moderated

    Sure

     

    I happen to have a repository already here:

    https://bitbucket.org/sco0ter/extfx

     

    I've pushed the DateAxis code there (extfx.scene.chart.DateAxis).

     

    Good luck with the animation stuff ;-)

  • 12. Re: Display Tooltips on charts values?
    ciruman Newbie
    Currently Being Moderated

    Thank you Christian,

     

    I will get it and see what can I do .

     

    Schönes Wochenende!

  • 13. Re: Display Tooltips on charts values?
    ciruman Newbie
    Currently Being Moderated

    Hi Christian,

     

    This is what I already fix/add:

     

    - The last value that is <= to the limit is also shown, then it looks nicer with the last value shown.

    - Animation fixed  . I have re-implement all the animation part of the setRange and another important override thing.- Fixed the ticks labels during the animation.

    - Fixed the ticks labels during the animation.

    - Fixed ranges also available.

    - Constructor with axis label added.

    - layout implemented.

     

    What I didn't do yet:


    - I didn't have time to implement the test and some manual test.

    - I didn´t implement yet a localization of the labels.

    - I don't know why the new point appears under the last point inserted and then is moved? I think that it shouldn't be difficult to implement this.

     

    I would say that we are closer to a nice DataAxis.

     

    Christian, how can I commit the changes?

     

    Have a nice weekend!

Legend

  • Correct Answers - 10 points
  • Helpful Answers - 5 points