Wie lässt sich eine Diagrammachse mit frei definierten Eigenschaften erstellen?

In JavaFX-Diagrammen wird die graphische Repräsentation der Daten im Wesentlichen durch die Diagrammachsen bestimmt. Sie definieren sowohl die Skalierung der Erscheinung, als auch die Formatierung der auf der Achse gezeigten Einheiten. Werden hier Änderungen notwendig, so müssen ggf. die Elternklassen erweitert werden.

Die in JavaFX für die Datenrepräsentation in Diagrammen üblicherweise verwendeten Achsentypen CategoryAxis für nicht-numerische und NumberAxis für numerische Daten, können bedauerlicherweise beide nicht direkt erweitert werden, da sie final deklariert sind. Im Falle von NumberAxis kann dies jedoch anhand der Elternklasse, ValueAxis, geschehen, wenn für den Standardfall nicht vorgesehene Änderungen an der Datenrepräsentation notwendig werden.

Die Beispielklasse

Das Beispiel demonstriert dies anhand eines Flächendiagramms, wie es bereits im gleichnamigen Artikel behandelt wurde. Dargestellt wird auch hier wieder ein fiktiver Gasverbrauch.
Allerdings soll die Anzeige der Einheiten auf der Y-Achse auf den Minimum-, den Maximum-, den 0-Wert, sowie einen zusätzlich zu ermittelnden Durchschnittswert beschränkt werden.
Zudem kann das Diagramm durch die Eingabe neuer Werte eines Monatsverbrauchs editiert werden.

Die Klasse AreaChartMitEingabe enthält main() und stellt das GUI des Programms bereit. Zu Beginn werden die beiden Diagrammachsen und eine Liste zur Speicherung des zu zeigenden Datensatzes als Felder deklariert (Zeile 3). Die Y-Achse wird hierbei durch eine Instanz einer von ValueAxis abgeleiteten CustomValueAxis realisiert, deren Konzept weiter unten besprochen wird. Beide Axis-Objekte werden in start() dem Konstruktor der AreaChart bei dessen Objektbildung übergeben (Zeile 11).

Nach der start()-Methode wird in createLayout() das Layout mit allen Komponenten erzeugt. Unter dem Flächendiagramm werden ein Auswahlmenu zur Monatsauswahl, ein Textfeld zur Eingabe eines Verbrauchswertes und ein Button gesetzt. Letzterer bewirkt durch Aufruf von updateData(), dass der im Texfeld eingetragene Wert dem ausgewählten Monat zugewiesen und im Diagramm angezeigt wird. Das Textfeld ist durch einen regulären Ausdruck so konfiguriert, dass es nur numerische Werte akzeptiert (Zeilen 53/54).

Die Methode createListData() wird in start() aufgerufen und erzeugt eine ArrayList, die Objekte vom Typ javafx.scene.chart.XYChart.Data speichert. Sie dient der Initialisierung des Diagramms, indem sie dem XYChart.Series-Objekt übergeben wird (Zeile 18). Es kapselt den gesamten im Diagramm gezeigten Datensatz und wird dem Chart-Objekt hinzugefügt (Zeile 19).

Die oberen und unteren Grenzwerte des Diagramms werden in den Methoden getMinBoundValue() und getMaxBoundValue() aus den Minimal- und Maximalwerten der Liste ermittelt. Sie werden zur Anpassung des Mittelwertes beim Update nach der Änderung der Daten ausgeführt (Zeile 134/135).

Die oben bereits angesprochene Methode updateData() wird bei Klick auf den Button aufgerufen und bewirkt die Aktualisierung der im Diagramm dargestellten Daten gemäß der Eingaben in Auswahlmenu und Textfeld. Schließlich werden der Mittelwert der Liste, sowie die obere und untere Grenze an das Objekt der Y-Achse übermittelt.
Den Mittelwert liefert computeAverage() als einfaches arithmetisches Mittel der in der Datenliste gespeicherten numerischen Werte.

public class AreaChartMitEingabe extends Application {

    private final CategoryAxis xAxis = new CategoryAxis();
    private final CustomValueAxis yAxis = new CustomValueAxis();
    private List<Data<String, Number>> list = new ArrayList<Data<String, Number>>();

    @Override
    public void start(Stage stage) {
        stage.setTitle("Area Chart Beispiel");
        xAxis.setLabel("Monat");
        final AreaChart<String, Number> ac = new AreaChart<>(xAxis, yAxis);

        XYChart.Series<String, Number> series = new XYChart.Series<String, Number>();
        series.setName("2020");

        createListData();

        series.setData(FXCollections.observableArrayList(list));
        ac.getData().add(series);
        yAxis.averageProperty().set(computeAverage(series));

        Scene scene = new Scene(createLayout(ac, series));
        String stylesheet = getClass().getResource("/styles/areaChartStyles.css").toExternalForm();
        scene.getStylesheets().add(stylesheet);
        stage.setScene(scene);
        stage.show();
    }

    private GridPane createLayout(AreaChart<String, Number> ac, AreaChart.Series<String, Number> series) {
        GridPane gp = new GridPane();
        ac.setPrefSize(800, 600);
        ac.setMaxSize(Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE);
        ac.setTitle("Gasverbrauch [m\u00b3]");
        gp.add(ac, 0, 0);
        GridPane.setColumnSpan(ac, 3);
        GridPane.setHgrow(ac, Priority.ALWAYS);

        ComboBox<String> cb = new ComboBox<String>();
        cb.setPromptText("Monat w\u00e4hlen");
        ObservableList<Data<String, Number>> list = series.getData();
        for (Data<String, Number> data : list) {
            String s = (String) data.XValueProperty().get();
            cb.getItems().addAll(s);
        }
        gp.add(cb, 0, 1);
        GridPane.setMargin(cb, new Insets(10, 10, 10, 50));

        TextField tf = new TextField();
        tf.setPromptText("Wert eintragen");
        tf.textProperty().addListener(new ChangeListener<String>() {
            @Override
            public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
                if (!newValue.matches("(-)\\d*")) {
                    tf.setText(newValue.replaceAll("[^(-)\\d]", ""));
                }
            }
        });
        gp.add(tf, 1, 1);
        GridPane.setMargin(tf, new Insets(10));

        Button butt = new Button("Einf\u00fcgen");
        butt.setOnAction(e -> updateData(series, cb.getValue(), tf.getText()));
        gp.add(butt, 2, 1);
        GridPane.setMargin(butt, new Insets(10));
        return gp;
    }
    
    private void createListData() {
        if(list == null) {
            list = new ArrayList<Data<String, Number>>();
        }
        list.add(new Data<String, Number>("Januar", 129d));
        list.add(new Data<String, Number>("Februar", 88d));
        list.add(new Data<String, Number>("M\u00e4rz", 69d));
        list.add(new Data<String, Number>("April", 15d));
        list.add(new Data<String, Number>("Mai", 3d));
        list.add(new Data<String, Number>("Juni", 1d));
        list.add(new Data<String, Number>("Juli", 0d));
        list.add(new Data<String, Number>("August", 0d));
        list.add(new Data<String, Number>("September", 0d));
        list.add(new Data<String, Number>("Oktober", 42d));
        list.add(new Data<String, Number>("November", 69d));
        list.add(new Data<String, Number>("Dezember", 133d));
    }

    // get the minimum value from list
    private double getMinBoundValue() {
        double result = Double.MAX_VALUE;
        for (Data<String, Number> d : list) {
            double n = (Double) d.getYValue();
            if (n < result) {
                result = n;
            }
        }
        return result;
    }

    // get the maximum value from list
    private double getMaxBoundValue() {
        double result = Double.MIN_VALUE;
        for (Data<String, Number> d : list) {
            double n = (Double) d.getYValue();
            if (n > result) {
                result = n;
            }
        }
        return result;
    }

    // compute the average of all list values
    private double computeAverage(XYChart.Series<String, Number> series) {
        ObservableList<Data<String, Number>> list = series.getData();
        double d = 0;
        for (Data<String, Number> data : list) {
            Number n = (Number) data.YValueProperty().get();
            d += n.doubleValue();
        }
        double av = list.size() > 0 ? d / list.size() : 0;
        return av;
    }

    private void updateData(XYChart.Series<String, Number> series, String month, String doubleVal) {
        if (month == null || doubleVal == null)
            return;
        double value = Double.parseDouble(doubleVal);
        ObservableList<Data<String, Number>> list = series.getData();
        for (Data<String, Number> data : list) {
            String s = (String) data.XValueProperty().get();
            if (s.endsWith(month)) {
                data.setYValue(value);
            }
        }
        yAxis.averageProperty().set(computeAverage(series));
        yAxis.upperBoundProperty().set(getMaxBoundValue());
        yAxis.lowerBoundProperty().set(getMinBoundValue());
    }

    public static void main(String[] args) {
        launch(args);
    }
}

CustomValueAxis als Erweiterung von ValueAxis

Die Klasse stellt eine numerische Y-Achse bereit, auf der nicht, wie als Standard vorgegeben, eine kontinuierliche Abfolge von Einheiten, sondern nur frei definierte Markierungen angezeigt werden. Dies sind hier der Maximal-, der Minimal-, der 0-Wert und der Durchschnitt aller im Diagramm angezeigten Werte.
Hierzu muss ValueAxis erweitert und dabei eine Reihe an Methoden überschrieben werden. Deren jeweilige Funktionen werden in der Folge skizziert.

setRange() und getRange()
Die Methoden setzen, bzw. liefern die obere und untere Anzeigegrenze der Achse unabhängig vom Umfang des Datensatzes. Sie werden im Wesentlichen dann verwendet, wenn AutoRanging auf false gesetzt ist.
autoRange()
Die Methode wird aufgerufen, wenn das AutoRanging, also die selbstständige Formatierung des Diagramms, aktiviert ist. Dies ist als Standard der Fall. Sie wird zur dynamischen Änderung der Anzeige aufgerufen und liefert die zur Formatierung notwendigen Werte.
calculateTickValues()
Hier wird eine Liste mit den auf der Achse markierten Punkte (Tickmarks) zurückgegeben Es ist wichtig, darauf zu achten, dass der untere und obere Grenzwert als erster und letzter Punkt in die Liste eingetragen werden, da es ansonsten dazu kommen kann, dass einzelne Punkte nicht angezeigt werden.
calculateMinorTickMarks()
In Diagrammen kann eine zweite Ebene an Markierungen, etwa in Form von Nachkommastellen, angezeigt werden, wenn der Methode setMinorTickVisible() der Parameter true übergeben wurde. Die Methode liefert eine Liste mit diesen Tickmarks. Hier wird eine leere Liste zurückgegeben, da die Funktion hier nicht benötigt wird.
getTickMarkLabel(Number value)
Die Methode dient der Formatierung der Markierungsanzeige. Sie liefert den String des formatierten Wertes. Im Beispiel wird die Anzeige auf eine Nachkommastelle beschränkt.
getDisplayPosition()
Die Methode steuert die Positionierung der Markierungen. Mittels Axis.getSide() kann die Ausrichtung der Achse ermittelt und die Position entsprechend angepasst werden.

Am Ende der Klasse, in Zeile 66, wird eine property average deklariert, die den Durchschnittswert des gezeigten Datensatzes abspeichert. Er wird aus dessen Werten in computeAverage() als arithmetisches Mittel errechnet.

class CustomValueAxis extends ValueAxis<Number> {

    public CustomValueAxis() {
        super();
    }

    @Override
    protected void setRange(Object range, boolean animate) {
        if (range != null) {
            Number lowerBound = ((Number[]) range)[0];
            Number upperBound = ((Number[]) range)[1];
            lowerBoundProperty().set(lowerBound.doubleValue());
            upperBoundProperty().set(upperBound.doubleValue());
        }
    }

    @Override
    protected Object getRange() {
        return new Double[] { lowerBoundProperty().get(), upperBoundProperty().get(), scaleProperty().get() };
    }

    @Override
    protected Object autoRange(double minValue, double maxValue, double length, double labelSize) {
        return new Double[] { minValue, maxValue };
    }

    @Override
    protected List<Number> calculateTickValues(double length, Object range) {
        ArrayList<Number> list = new ArrayList<Number>();
        if (range != null) {
            Number lowerBound = ((Number[]) range)[0];
            Number upperBound = ((Number[]) range)[1];
            list.add(lowerBound);
            list.add(0d);
            list.add(averageProperty().getValue()); // Mittelwert
            list.add(upperBound);
        }
        return list;
    }

    @Override
    protected List<Number> calculateMinorTickMarks() {
        return new ArrayList<Number>();
    }

    @Override
    protected String getTickMarkLabel(Number value) {
        NumberFormat formatter = NumberFormat.getInstance();
        formatter.setMaximumFractionDigits(1);
        return formatter.format(value);
    }

    @Override
    public double getDisplayPosition(Number value) {
        double delta = upperBoundProperty().get() - lowerBoundProperty().get();
        double val = value.doubleValue() - lowerBoundProperty().get();
        if (getSide().isVertical()) {
            return (1 - (val / delta)) * getHeight();
        } else {
            return (val / delta) * getWidth();
        }
    }

    // ------------------------ Average Property ----------------------/

    private SimpleDoubleProperty average = new SimpleDoubleProperty();

    public final DoubleProperty averageProperty() {
        if (average == null) {
            average = new SimpleDoubleProperty(0);
        }
        return average;
    }

    public final double getAverage() {
        if (average != null)
            return average.get();
        return 0;
    }
}
Anzeige des Durchschnittswertes auf abgeleiteter ValueAxis
Quellen
  1. https://docs.oracle.com/javase/8/javafx/user-interface-tutorial/area-chart.htm#CIHCFGBA
  2. http://blog.dooapp.com/2013/06/logarithmic-scale-strikes-back-in.html

Wenn Ihnen javabeginners.de gefällt, freue ich mich über eine Spende an diese gemeinnützigen Organisationen.