TweetWallFX – Part 4

JavaFX comes along with a highly customizable and powerful chart API out of the box: bar chart, pie chart, area chart and much more. And this API also provides you event handler to provide a high user experience allowing end-users to interact with the charts. Maybe you already know JFreeChart which still is a reference in the Java SE world but also used in some other third party libraries for Java EE. You can see the JavaFX Chart API as a really nice evolution of JFreeChart: powerful, highly customizable and out of the box!

TweetWallFX uses this API in order to display some statistics about the number of tweets captured by the wall. Typically it displays the number of tweets by hour with two main functions:

  • display the number of tweets while having the mouse over a symbol on the chart;
  • display the number of tweets by minutes when clicking on a chart symbol.

In this article we’ll focus on displaying the tweets by hour, customize our chart and define a little “mouse over” interaction.

Shall we chart?

It begins to be cleared that we’ll use a FXML file for the view and a controller. So let’s start with the FXML, named statistics-controller.fxml. As you will see, the root element is a Tab because we’ll display our chart in a specific tab, coming along with a wall.

<?xml version="1.0" encoding="UTF-8"?>

<?import java.lang.*?>
<?import javafx.collections.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.chart.*?>
<?import javafx.scene.chart.XYChart.* ?>
<?import javafx.util.converter.* ?>
<?import com.twasyl.tweetwallfx.util.converter.* ?>

<Tab xmlns:fx="http://javafx.com/fxml" fx:controller="com.twasyl.tweetwallfx.controllers.StatisticsController" closable="true">
  <content>
    <HBox style="-fx-background-color: linear-gradient(#5C5C5C 0%, #2E2E2E 100%);" alignment="CENTER" minWidth="1024" minHeight="768">
      <LineChart fx:id="chart" prefWidth="1010" prefHeight="700" legendVisible="false">
        <xAxis>
          <CategoryAxis label="Time">
            <categories>
              <FXCollections fx:factory="observableArrayList" />
            </categories>
          </CategoryAxis>
        </xAxis>
        <yAxis>
          <NumberAxis label="Number of tweets" tickUnit="10" minorTickVisible="false">
            <tickLabelFormatter>
              <NoDecimalConverter />
            </tickLabelFormatter>
          </NumberAxis>
        </yAxis>
        <data>
          <FXCollections fx:factory="observableArrayList">
            <XYChart.Series>
              <data>
                <FXCollections fx:factory="observableArrayList" />
              </data>
            </XYChart.Series>
          </FXCollections>
        </data>
      </LineChart>
    </HBox>
  </content>
</Tab>

I’ll focus on the chart creation, which is here a line chart with only one serie. As you can see I declare my <xAxis>…</xAxis> which represents the hours where tweets have been generated. It is a CategoryAxis because here, the value will be a string like 20h. The values for this axis are stored in a list, created here using <FXCollections … />. The Y axis is an axis which values are a number, so a NumberAxis. You can notice the <tickLabelFormatter /> that is a class that formats the value on the Y axis. Typically the one used won’t display decimals and looks like this:

public class NoDecimalConverter extends StringConverter<Number> {

  @Override
  public String toString(Number arg0) {
    return String.format("%1$s", arg0.intValue());
  }

  @Override
  public Number fromString(String arg0) {
    return Integer.parseInt(arg0);
  }
}

Like the axises we create a serie for our values, which are also stored inside a list created using the FXCollections factory. Before creating the controller, we will create a CSS file, statistics-style.css, in order to customize the look of our chart:

.axis {
  -fx-text-fill: white;
  -fx-tick-label-fill: white;
}
.axis-label {
  -fx-text-fill: white;
}
.chart-title {
  -fx-text-fill: white;
}
.chart-line-symbol {
  -fx-background-color: radial-gradient(radius 100%, deepskyblue, dodgerblue, royalblue);
  -fx-shape: "M197.223,82.571c-5.611,0.958-13.75-0.038-18.061-1.832c8.96-0.742,15.027-4.815,17.365-10.343 c-3.229,1.988-13.26,4.153-18.793,2.09c-0.275-1.301-0.575-2.539-0.879-3.66c-4.213-15.506-18.663-28.001-33.794-26.491 c1.22-0.495,2.458-0.955,3.708-1.375c1.656-0.597,11.436-2.192,9.898-5.639c-1.303-3.041-13.249,2.287-15.496,2.987 c2.969-1.113,7.881-3.033,8.403-6.451c-4.546,0.623-9.012,2.777-12.46,5.906c1.248-1.34,2.191-2.974,2.391-4.737 c-12.133,7.762-19.222,23.396-24.955,38.574c-4.501-4.372-8.502-7.812-12.079-9.729c-10.043-5.384-22.057-11.012-40.906-18.019 c-0.58,6.243,3.083,14.55,13.63,20.066c-2.283-0.309-6.462,0.383-9.799,1.177c1.359,7.16,5.805,13.052,17.851,15.898 c-5.503,0.362-8.353,1.624-10.928,4.318c2.506,4.978,8.63,10.831,19.628,9.627c-12.24,5.282-4.992,15.061,4.968,13.601 c-16.98,17.566-43.761,16.263-59.139,1.583c40.145,54.762,127.421,32.382,140.421-20.363 C187.951,89.841,193.672,86.386,197.223,82.571z";
  -fx-scale-shape: false;
  -fx-scale-x: 0.2;
  -fx-scale-y: 0.2;
}
.chart-series-line { -fx-stroke: #69B4E4; }

In this file we redefine the color of the text for the labels, the chart title, the color of the lines and, the most interesting part, the line symbol. You can see the -fx-shape attribute that has a “strange” value. This value is simply the SVG path drawing a twitter bird! Not kidding! JavaFX supports natively SVG! You can use it like here in CSS, or in your FXML using the SVGPath object. And to conclude with the UI part, we’ll create a FXML file, tooltip.fxml, to display a tooltip when having the mouse over a line symbol:

<HBox xmlns:fx="http://javafx.com/fxml" fx:id="pane" spacing="10" fx:controller="com.twasyl.tweetwallfx.controllers.TooltipController"
        style="-fx-background-color: linear-gradient(beige 0%, khaki 100%); -fx-background-radius: 10,10,10,10; -fx-padding: 5,5,5,5">
  <children>
    <Label fx:id="text" />
  </children>
</HBox>

Business class

In order to represent statistics in a scalable way, we will create a statistics class:

public class TweetStatistics implements Serializable {
  private IntegerProperty yearProperty;
  private IntegerProperty monthProperty;
  private IntegerProperty dayOfMonthProperty;
  private IntegerProperty hourProperty;
  private MapProperty<Integer, Integer> minutesProperty;
  private ReadOnlyIntegerProperty totalTweetsProperty;
    
  public TweetStatistics() {
    this.yearProperty = new SimpleIntegerProperty(0);
    this.monthProperty = new SimpleIntegerProperty(0);
    this.dayOfMonthProperty = new SimpleIntegerProperty(0);
    this.hourProperty = new SimpleIntegerProperty(0);
    this.minutesProperty = new SimpleMapProperty<Integer, Integer>();
    this.totalTweetsProperty = new SimpleIntegerProperty(0);
    
    this.minutesProperty.addListener(new MapChangeListener<Integer, Integer>() {
      @Override
      public void onChanged(Change<? extends Integer, ? extends Integer> event) {
        int count = 0;
        for(Entry<Integer, Integer> entry : TweetStatistics.this.minutesProperty().get().entrySet()) {
          count += entry.getValue();
        }
        ((SimpleIntegerProperty) TweetStatistics.this.totalTweetsProperty).set(count);
      }
    });
  }

  public TweetStatistics(int year, int month, int dayOfMonth,int hour, ObservableMap<Integer, Integer> minutes) {
    this();
    setYear(year);
    setMonth(month);
    setDayOfMonth(dayOfMonth);
    setHour(hour);
    setMinutes(minutes);
  }
    
  // Getter & setter
    
  public boolean tweetMatchesStatistics(Tweet tweet) {
    boolean result = false;
    if(tweet != null && tweet.getPostDate() != null) {
      int year = tweet.getPostDate().get(Calendar.YEAR);
      int month = tweet.getPostDate().get(Calendar.MONTH);
      int dom = tweet.getPostDate().get(Calendar.DAY_OF_MONTH);
      int hour = tweet.getPostDate().get(Calendar.HOUR_OF_DAY);
     
      result = year == getYear() && month == getMonth() &&
              dom == getDayOfMonth() && hour == getHour();
    }
    return result;
  }
  
  /**
   * Appends the tweet to the stats. This method calls tweetMatchesStatistics
   * @param tweet 
   */
  public boolean appendTweet(Tweet tweet) {
    boolean added = false;
    
    if(tweetMatchesStatistics(tweet)) {
      int number = 1;
      if(getMinutes().containsKey(tweet.getPostDate().get(Calendar.MINUTE))) {
        number = getMinutes().get(tweet.getPostDate().get(Calendar.MINUTE)) + 1;
      }
      getMinutes().put(tweet.getPostDate().get(Calendar.MINUTE), number);
      added = true;
    }
    return added;
  }
}

Controllers

We’ll start with the easiest one: the tooltip controller which has nothing to explain:

public class TooltipController {
  @FXML private Pane pane;
  @FXML private Label text;

  public Pane getPane() {
    return pane;
  }

  public Label getText() {
    return text;
  }
}

The StatisticsController is going to be a little bit more complex. Because the controller implements the Initializable interface, let’s see the initilize method:

public class StatisticsController implements Initializable {
  @FXML
  private LineChart<String, Integer> chart;
  private ObservableList<TweetStatistics> statistics;

  // ...
  @Override
  public void initialize(URL arg0, ResourceBundle arg1) {
    this.chart.getStylesheets().add(getClass().getResource("/com/twasyl/tweetwallfx/css/statistics-style.css").toExternalForm());
    this.statistics = FXCollections.observableArrayList();
    this.statistics.addListener(new StatisticsChangeListener());
  }
  // ...
}

You can see how to apply a CSS file to a Node, here our chart. We’re also adding a listener to our list of statistics in order to handle changes into hit. This listener is the following inner-class:

private class StatisticsChangeListener implements ListChangeListener<TweetStatistics> {
  @Override
  public void onChanged(Change<? extends TweetStatistics> event) {
    // We retrieve the one and only serie defined in the chart.
    // The serie's been created in the FXML file
    XYChart.Series<String, Integer> series = StatisticsController.this.chart.getData().get(0);
    boolean dataExists;
    short index;

   // Many events could have occurred, this is the reason of this loop
    while(event.next()) {
      // We get the sublist that contains modifications
      // We get the list this way in order not to care
      // if it was additions or removals
      List<TweetStatistics> modifications = (List<TweetStatistics>) event.getList().subList(event.getFrom(), event.getTo());

      // For each modifications, we look if the data already exists and need an update.
      // If the data doesn't exist, add it
      for(TweetStatistics ts : modifications) {
        index = 0;
        dataExists = false;
        while(!dataExists && index < series.getData().size()) {
          dataExists = series.getData().get(index).getXValue().equals(ts.getHour() + "h");
          index++;
        }

        // If the data does not exist, create it
        if(!dataExists) {
          // The third argument of the constructor for a Data is the
          // extra value
          Data<String, Integer> data = new Data<String, Integer>(
                                String.format("%1$sh", ts.getHour()), 
                                ts.getTotalTweets(), ts);
          series.getData().add(data);
          data.getNode().addEventHandler(EventType.ROOT, new ChartDataEventHandler(data));
        } else {
          Data<String, Integer> data = series.getData().get(index-1);
          data.setYValue(ts.getTotalTweets());
        }
      }
    }
  }
}

So far so good. Now another inner-class, the ChartDataEventHandler class that will handle the mouse over a line symbol of the chart, as well as the click. We’ll just cover the mouse over event.

private class ChartDataEventHandler implements EventHandler<Event> {
  Data data;
  TooltipController controller;

  public ChartDataEventHandler(Data data) {
    this.data = data;
  }

  @Override
  public void handle(Event event) {
    if (event instanceof MouseEvent) {
      MouseEvent mouseEvent = (MouseEvent) event;

      // When the mouse enter the line symbol, we have to create
      // and display the tooltip
      if (event.getEventType().equals(MouseEvent.MOUSE_ENTERED)) {
        setController();

        // If the controller of the tooltip doesn't exist, create and display it
        if (controller != null) {
          controller.getText().setText("Number of tweets: " + data.getYValue());
          double x = mouseEvent.getSceneX() + 5;
          double y = mouseEvent.getSceneY() + 5;

          controller.getPane().autosize();
          controller.getPane().setLayoutX(x);
          controller.getPane().setLayoutY(y);
          ((Pane) data.getNode().getScene().getRoot()).getChildren().add(controller.getPane());
        }
      } else if (event.getEventType().equals(MouseEvent.MOUSE_EXITED)) {
        // When the mouse exits the line symbol, remove the tooltip
        if (controller != null) {
          ((Pane) data.getNode().getScene().getRoot()).getChildren().remove(controller.getPane());
          controller = null;
        }
      } else if (event.getEventType().equals(MouseEvent.MOUSE_MOVED)) {
        // When the mouse moves over the line symbol,
        // move the tooltip
        if (controller != null) {
          double x = mouseEvent.getSceneX() + 5;
          double y = mouseEvent.getSceneY() + 5;
          controller.getPane().setLayoutX(x);
          controller.getPane().setLayoutY(y);
        }
      } else if (event.getEventType().equals(MouseEvent.MOUSE_CLICKED)) {
        // Manage the click if you want
      }
    }
  }

  public void setController() {
    FXMLLoader fxml = new FXMLLoader(FXMLLibrary.getFXMLUrl(FXMLLibrary.TOOLTIP_FXML), ResourceBundleLibrary.getApplicationPropertiesRB());
    try {
      fxml.load();
      controller = (TooltipController) fxml.getController();
    } catch (IOException ex) {
      Logger.getLogger(StatisticsController.class.getName()).log(Level.SEVERE, null, ex);
    }
  }
}

And finally a method that will trigger the changes of the chart:

public void addTweet(Tweet tweet) {
  if(tweet != null) {
    boolean done = false;
    short index = 0;

    while(index < this.statistics.size() && !done) {
      done = this.statistics.get(index).appendTweet(tweet);
      if(done) {
        // Just to fire a change in the list
        this.statistics.set(index, this.statistics.get(index));
      }
      index++;
    }
    if(!done) {
      TweetStatistics ts = new TweetStatistics(tweet.getPostDate().get(Calendar.YEAR),
                        tweet.getPostDate().get(Calendar.MONTH),
                        tweet.getPostDate().get(Calendar.DAY_OF_MONTH), 
                        tweet.getPostDate().get(Calendar.HOUR_OF_DAY), 
                        null);
      ts.appendTweet(tweet);
      this.statistics.add(ts);
    }
  }
}

The final result gives you this:
Well this is it for this long and technical post.

One Response to TweetWallFX – Part 4

  1. Pingback: TweetWallFX – Part 5 « Thierry WASYL : Java blog

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: