package visualiser.model; import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import javafx.scene.paint.Color; import shared.model.Leg; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Class to process and modify a sparkline display. This display keeps visual * track of {@link VisualiserBoat}s in a race and their current * placing position as they complete each {@link shared.model.Leg} by * passing a course {@link shared.model.Mark}.
* This sparkline is displayed using the * {@link visualiser.Controllers.RaceViewController}. */ public class Sparkline { /** * The race to observe. */ private VisualiserRaceState race; /** * The boats to observe. */ private ObservableList boats; /** * Race legs to observe. * We need to observe legs as they may be added after the sparkline is created if race.xml is received after this is created. */ private ObservableList legs; /** * The linchart to plot sparklines on. */ private LineChart sparklineChart; /** * The x axis of the sparkline chart. */ private NumberAxis xAxis; /** * The y axis of the sparkline chart. */ private NumberAxis yAxis; /** * A map between a boat and its data series in the sparkline. * This is used so that we can remove a series when (or if) a boat is removed from the race. */ private Map> boatSeriesMap; /** * Constructor to set up initial sparkline (LineChart) object * @param race The race to listen to. * @param sparklineChart JavaFX LineChart for the sparkline. */ public Sparkline(VisualiserRaceState race, LineChart sparklineChart) { this.race = race; this.boats = new SortedList<>(race.getBoats()); this.legs = race.getLegs(); this.sparklineChart = sparklineChart; this.yAxis = (NumberAxis) sparklineChart.getYAxis(); this.xAxis = (NumberAxis) sparklineChart.getXAxis(); this.boatSeriesMap = new HashMap<>(); createSparkline(); } /** * Creates and sets initial display for Sparkline for race positions. * A data series for each boat in the race is added. * Position numbers are displayed. */ private void createSparkline() { //We need to dynamically update the sparkline when boats are added/removed. boats.addListener((ListChangeListener.Change c) -> { Platform.runLater(() -> { while (c.next()) { if (c.wasAdded()) { for (VisualiserBoat boat : c.getAddedSubList()) { addBoatSeries(boat); } } else if (c.wasRemoved()) { for (VisualiserBoat boat : c.getRemoved()) { removeBoatSeries(boat); } } } //Update height of y axis. setYAxisLowerBound(); }); }); legs.addListener((ListChangeListener.Change c) -> { Platform.runLater(() -> xAxis.setUpperBound(race.getLegCount())); }); //Initialise chart for existing boats. for (VisualiserBoat boat : boats) { addBoatSeries(boat); } sparklineChart.setCreateSymbols(false); //Set x axis details xAxis.setAutoRanging(false); xAxis.setTickMarkVisible(false); xAxis.setTickLabelsVisible(false); xAxis.setMinorTickVisible(false); xAxis.setLowerBound(0); xAxis.setUpperBound(race.getLegCount()); xAxis.setTickUnit(1); //The y-axis uses negative values, with the minus sign hidden (e.g., boat in 1st has position -1, which becomes 1, boat in 6th has position -6, which becomes 6). //This is necessary to actually get the y-axis labelled correctly. Negative tick count doesn't work. //Set y axis details yAxis.setAutoRanging(false); yAxis.setTickUnit(1); yAxis.setMinorTickCount(0); yAxis.setUpperBound(0); setYAxisLowerBound(); yAxis.setLabel("Position in Race"); yAxis.setTickMarkVisible(true); yAxis.setTickLabelsVisible(true); yAxis.setMinorTickVisible(false); //Hide minus number from displaying on axis. yAxis.setTickLabelFormatter(new NumberAxis.DefaultFormatter(yAxis) { @Override public String toString(Number value) { if ((value.intValue() == 0) || (value.intValue() < -boats.size())) { return ""; } else { return String.format("%d", -value.intValue()); } } }); } /** * Sets the lower bound of the y-axis. */ private void setYAxisLowerBound() { yAxis.setLowerBound( -(boats.size() + 1)); } /** * Removes the data series for a given boat from the sparkline. * @param boat Boat to remove series for. */ private void removeBoatSeries(VisualiserBoat boat) { sparklineChart.getData().remove(boatSeriesMap.get(boat)); boatSeriesMap.remove(boat); } /** * Creates a data series for a boat, and adds it to the sparkline. * @param boat Boat to add series for. */ private void addBoatSeries(VisualiserBoat boat) { //Create data series for boat. XYChart.Series series = new XYChart.Series<>(); //All boats start in "last" place. series.getData().add(new XYChart.Data<>(0, -(boats.size()))); //Listen for changes in the boat's leg - we only update the graph when it changes leg. boat.legProperty().addListener( (observable, oldValue, newValue) -> { //Get the data to plot. List boatOrder = race.getLegCompletionOrder().get(oldValue); //Find boat position in list. int boatPosition = -(boatOrder.indexOf(boat) + 1); //Get leg number. int legNumber = oldValue.getLegNumber() + 1; //Create new data point for boat's position at the new leg. XYChart.Data dataPoint = new XYChart.Data<>(legNumber, boatPosition); //Add to series. Platform.runLater(() -> series.getData().add(dataPoint)); }); //Add to chart. sparklineChart.getData().add(series); //Color using boat's color. We need to do this after adding the series to a chart, otherwise we get null pointer exceptions. series.getNode().setStyle("-fx-stroke: " + colourToHex(boat.getColor()) + ";"); boatSeriesMap.put(boat, series); } /** * Converts a color to a hex string, starting with a {@literal #} symbol. * @param color The color to convert. * @return Hex string of the color (e.g., {@literal "#11AB4C"}). */ private static String colourToHex(Color color) { return String.format( "#%02X%02X%02X", (int)( color.getRed() * 255 ), (int)( color.getGreen() * 255 ), (int)( color.getBlue() * 255 ) ); } }