Autorenarchiv

Datenvisualisierung mit JavaFX

Mittwoch, 25. März 2009

Wer sich als Java-Entwickler ein wenig in die zunächst ungewohnte Syntax von JavaFX eingearbeitet hat, stellt sich dann möglicherweise die Frage, wo sich die neuerworbenen Kenntnisse auch abseits von Mediaplayern, Fotoalben und Spielen sinnvoll einsetzen lassen. Ein Bereich, für den sich die Verwendung von JavaFX anbietet, ist die Visualisierung von Daten. Insbesondere wenn sich Diagramme dynamisch an veränderte Daten anpassen sollen, kann JavaFX seine Stärken ausspielen. Die Schlagworte heißen dabei Data Binding und Replace Triggers. Wie so etwas aussehen kann, soll in diesem Blogeintrag anhand eines Beispiels veranschaulicht werden. Auf Syntax und die grundlegenden Konzepte von JavaFX soll hier nicht näher eingegangen werden. Hierfür sei auf die offiziellen Hilfeseiten und insbesondere auf das Videotutorial von Robert Eckstein verwiesen. Weiterhin sollte an dieser Stelle nicht unerwähnt bleiben, dass die Entwicklung mit JavaFX derzeit noch auf die Betriebssysteme Windows und Mac Os X beschränkt ist und dass das JavaFX Plug-In für Eclipse gegenüber dem Plug-In für Netbeans deutlich weniger ausgereift wirkt.
Für unser Beispiel sollen ein Balken- und ein Kuchendiagramm erzeugt werden. Diese sollen sich dynamisch an die 5 verschiedenen Eingangsdaten anpassen. Mit Hilfe von Schiebereglern können die einzelnen Eingangswerte verändert werden. Das Balkendiagramm veranschaulicht die absoluten Werte und das Kuchendiagramm die Anteile der einzelnen Werte an der Gesamtsumme.

Abb.: Die fertige Beispielanwendung

Abb.: Die fertige Beispielanwendung

Die hier vorgestellte Beispielanwendung besteht aus 3 Dateien:
1. Das Hauptskript (Main.fx): Hier wird der Szenengraph zusammengesetzt. Dieser enthält hauptsächlich die beiden Diagramme und die 5 Schieberegler zur Steuerung der Eingangsdaten, die mittels HBox- und VBox-Knoten angeordnet werden. Hier wird intensiv Data Binding verwendet, um Werte aneinander zu binden:

  1. in Zeile 28 wird die Sequence (Array-artige Struktur in JavaFX) heights an die Sequence sliders gebunden. Jede Änderung an einem Element von sliders löst automatisch eine Änderung am entsprechenden Element von heights aus. Wird z.B. der erste Slider bewegt, so wird sein neuer Wert verwendet, um den neuen Wert des entsprechenden Elements von heights zu berechnen.
  2. in Zeile 33 wird die Variable sum an die Werte der der einzelnen Slider gebunden. Ändert sich einer dieser Werte, wird automatisch die Summe neu berechnet.
  3. in Zeile 38 wird schließlich die Berechnung der prozentualen Anteile ebenfalls an die Slider gebunden, so dass die Verschiebung eines Sliders auch hier eine Neuberechnung anstößt. Hier kommt auch das bereits oben erwähnte Konzept der Replace Triggers (eingeleitet durch on replace) zum Einsatz. Die Änderung an der Variablen percentages löst den Aufruf der updatePieces-Funktion aus.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package datenvisualisierung;
import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.layout.VBox;
import javafx.scene.layout.HBox;
import javafx.ext.swing.SwingLabel;
import javafx.scene.shape.Rectangle;
import javafx.ext.swing.SwingSlider;
 
def colors = [Color.GREY,Color.RED,Color.MAGENTA,
                 Color.BLUE,Color.GREEN];
def sliders: SwingSlider[] = for (i in [0..4]) {
    SwingSlider {
        maximum: 100,
        minimum: 1,
        value: 20
    }
}
 
def pieChart = PieChart {
    colors: colors
}
 
def barChart = BarChart {
    colors: colors,
    heights: bind for (s in sliders){
        s.value * 3
    }
}
 
var sum = bind (sliders[0].value + 
                sliders[1].value + 
                sliders[2].value + 
                sliders[3].value + 
                sliders[4].value);
def percentages: Number[] = bind for (s in sliders)
    (s.value * 100.0) / (sum) on replace {
        pieChart.updatePieces(percentages)
    };
 
Stage {
    title: "Interaktive Diagramme mit Data Binding"
    width: 500
    height: 400
    scene: Scene {
        fill: Color.WHITE
        content: [
            pieChart,
            barChart,
            VBox {
 
                translateX: 200,
                translateY: 20,
                content: [
                    for (i in [0..4]){
                        HBox{
                            spacing: 20,
                            content: [
                                Rectangle {
                                    width: 15,
                                    height: 15
                                    fill: colors[i]
                                },
                                SwingLabel {
                                    text: "data{i}"
                                },
                                sliders[i]
                            ]
                        }
                    }
                ]
            }
        ]
    }
}

2. Die Klasse BarChart (BarChart.fx) erbt von CustomNode (das ist die Basisklasse für alle eigenen Knoten) und stellt das Balkendiagramm als Knoten für den Szenengraph bereit. Diese Klasse beinhaltet 5 Rechtecke, die die einzelnen Balken des Diagrammes repräsentieren. Über Data Binding werden die y-Position (Attribut y) und die Höhe (Attribut height) der einzelnen Balken an die jeweiligen Werte der Sequence heights gebunden. Ändert sich ein Wert in heights, werden auch die daran gebundenen Werte, d.h. die Attribute y und height neu berechnet und die Darstellung ändert sich automatisch. Um die Darstellung etwas ansprechender zu gestalten, verfügen sowohl BarChart als auch PieChart über einen einfachen Schatteneffekt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package datenvisualisierung;
import javafx.scene.Group;
import javafx.scene.CustomNode;
import javafx.scene.paint.Color;
import javafx.scene.effect.DropShadow;
import javafx.scene.shape.Rectangle;
 
public class BarChart extends CustomNode {
    public-init var colors:Color[];
    def bars = for (i in [0 .. 4]){
        Rectangle {
            x: 30 + i * 20,
            y: bind 350 - heights[i],
            height: bind heights[i],
            stroke: Color.BLACK,
            width: 20,
            fill: colors[i]
        }
    }
 
    override function create() {
        return group;
    }
 
    def group:Group = Group{
        effect: DropShadow {
            offsetX: 10,
            offsetY: 10,
            color: Color.web("#404040"),
            radius: 10
        }
        content: bars
    };
 
    package var heights:Integer[] = [100,100,100,100,100];
}

3. Die Klasse PieChart (PieChart.fx) erbt ebenfalls von CustomNode und erzeugt ein Kuchendiagramm als Knoten für den Szenengraph. Hier wird die Synchronisierung über den oben bereits angesprochenen Replace Trigger Mechanismus erreicht.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package datenvisualisierung;
import javafx.scene.Group;
import javafx.scene.CustomNode;
import javafx.scene.paint.Color;
import javafx.scene.effect.DropShadow;
import javafx.scene.shape.Arc;
import javafx.scene.shape.ArcType;
 
public class PieChart extends CustomNode {
    public-init var colors:Color[];
    def pieces = for (i in [0 .. 4]){
        Arc {
            centerX: 350,
            centerY: 250,
            radiusX: 100,
            radiusY: 100,
            startAngle: i*20,
            length: 72
            fill: colors[i],
            stroke: Color.BLACK,
            type: ArcType.ROUND
        };
    }
 
    override function create() {
        return group;
    }
 
    def group:Group = Group{
        effect: DropShadow {
            offsetX: 10,
            offsetY: 10,
            color: Color.web("#404040"),
            radius: 10
        }
        content: pieces
    };
 
    public function updatePieces(percentages:Number[]){
        var start: Number = 0;
        for (i in [0..4]){
            if (i > 0) {
                start = percentages[
                i - 1] * 3.6 + start;
            }
            pieces[i].startAngle=start;
            pieces[i].length=percentages[i] * 3.6;
        }
    }
}

Anhand dieses einfachen Beispiels kann man vielleicht die Möglichkeiten erahnen, die JavaFX für die Visualisierung von Daten und die GUI-Entwicklung als Ganzes bietet. Durch die deklarative Syntax und das Szenengraph-Konzept bleibt der Code kompakt und (meistens) übersichtlich.
Ich hoffe, dass das hier gezeigte Beispiel einige Anregungen für eigene Experimente mit JavaFX liefert und wünsche viel Spaß beim Ausprobieren.


Malte Wulf


Generics und Wildcards

Freitag, 20. Februar 2009

Wer sich bereits etwas eingehender mit Generics in Java beschäftigt hat, wird bestätigen, dass dort einige Fallstricke lauern und die Syntax dabei mitunter sehr seltsame Formen annimmt – insbesondere wenn dabei auch noch Wildcards mit im Spiel sind. In diesem Beitrag sollen einige auf den ersten Blick verwirrende Konstellationen gezeigt und erläutert werden.

Für die im Anschluß folgenden Beispiele führen wir zunächst einige einfache Interfaces und Klassen ein:

interface Interface1 {
}
 
interface Interface2 {
}
 
class SuperKl implements Interface1 , Interface2 {
}
 
class Klasse1 extends SuperKl {
}
 
class Subkl extends Klasse1 {
}

Wenn man eine Methode

public void methode(List<Superkl> liste) {
// do something ...
}

verwenden möchte, darf man bekanntlich als Argument nur Listen mit Superkl-Elementen verwenden, nicht aber Listen für Klasse1. So ist

methode( new LinkedList<Superkl>() );

ein gültiger Aufruf, während

methode( new LinkedList<Klasse1>() );

vom Compiler mit einer Fehlermeldung bestraft wird. Diese Variante (nennen wir sie Variante 1) beschränkt den Typparameter der Liste innerhalb der Typhierarchie nach oben und nach unten, so dass dort weder Superklassen noch Subklassen der Klasse Superkl zulässig sind. Dabei ist zu beachten, dass sich dies auf den Typparameter – nicht aber auf die Listenklasse selbst bezieht. Es können hier z.B. LinkedList<Superkl> oder ArrayList<Superkl> verwendet werden.

Will man die enge Beschränkung lockern (um z.B. auch Listen von Klasse1-Elementen als Argument zuzulassen), kann man Wildcards verwenden. Dies führt zu drei weiteren Varianten:

  • die in der Typauswahl unbeschränkte Variante 2
    public void methode(List<?> liste) {
    // do something ...
    }
  • die in der Typhierarchie nach oben beschränkte Variante 3 (wenn man das ganze auf Subklassen von Superkl beschränken möchte)
    public void methode(List<? extends Superkl> liste) {
    // do something ...
    }
  • die in der Typhierarchie nach unten beschränkte Variante 4 (wenn man sich auf Superklassen von Subkl beschränken möchte)
    public void methode(List<? super Subkl> liste) {
    // do something ...
    }

Die insgesamt vier vorgestellten Varianten unterscheiden sich also im Freiheitsgrad des generischen Typparameters in der Typhierarchie. Dabei gilt

  • Ist der Typ nach oben beschränkt (wie es in Variante 1 und 3 der Fall ist), so können die Methoden und Eigenschaften des Parametertyps (in unserem Beispiel Superkl) ohne zusätzliche Cast verwendet werden, da sichergestellt ist, dass alle in der Liste enthaltenen Elemente von diesem Typ erben.
  • Damit man innerhalb der Methode weitere Elemente in die Liste einfügen kann, ist es nötig, dass der Typ nach unten beschränkt ist (wie es in Variante 1 und 4 der Fall ist). Wäre dies nicht notwendig, so könnte man beispielsweise auch eine List<Subkl> als Argument übergeben und dieser dann in der Methode Elemente vom Typ Klasse1 unterschieben. Die Typsicherheit wäre nicht mehr gewährleistet.

Zuguterletzt ist beim Einsatz von Generics aber immer zu bedenken, dass sie mitunter die Lesbarkeit des Quellcodes erheblich beeinträchtigen. Dies wird beispielsweise an der folgenden generischen Methode deutlich:

public <T extends  Superkl & Interface1 & Interface2> void beispielMethode(T t){
// do something ...
}

Diese kann nur für Klassen T verwendet werden, die Subklasse von Superkl sind und zusätzlich beide Interfaces implementieren. Bei der Deklaration einer solchen Methode sind folgende Punkte zu beachten:

  1. Die Typvariable T muß vor dem Rückgabewert der Methode deklariert werden.
  2. Auch wenn T nur Interfaces implementieren soll, ist stets das Schlüsselwort extends zu verwenden.
  3. Bei der Deklaration von T ist darauf zu achten, dass T selbstverständlich nur eine Superklasse haben aber beliebig viele Interfaces implementieren darf (die einzelnen Einträge werden dabei nicht etwa durch ein Komma sondern durch ein & getrennt).
  4. Wenn T eine Superklasse hat, muß diese vor den Interfaces angegeben werden.

Angesichts solcher Konstrukte läßt sich leicht nachvollziehen, dass Generics besonders gegenüber Einsteigern eine abschreckende Wirkung entfalten.


Malte Wulf


Volltextsuche in persistierten Objekten mit Hibernate Search

Montag, 01. Dezember 2008

Das Hibernate Projekt bietet mit Hibernate Search die Möglichkeit, die in einer Datenbank persistierten Objekte bequem zu durchsuchen. Als Suchmaschine für die Volltextsuche kommt dabei Apache Lucene zum Einsatz. Die Einrichtung ist in nur wenigen Schritten erledigt:

  1. Zusätzlich zur vorhandenen Hibernate Installation müssen die Jar-Dateien aus dem Hibernate Search Release in das eigene Projekt eingebunden werden.
  2. Die Hibernate Konfiguration muss um 2 Properties ergänzt werden:
    <session-factory>
    ...
    <property name="hibernate.search.default.directory_provider">
    org.hibernate.search.store.FSDirectoryProvider
    </property>
    <property name="hibernate.search.default.indexBase">
    luceneIndexPfad
    </property>
    ...
    <session-factory>

    Die erste Property legt dabei den Directory-Provider fest. In diesem Fall wird der FSDirectoryProvider gewählt, der den Index für die Volltextsuche im Filesystem ablegt. Die zweite Property gibt das Basisverzeichnis für diesen Index an. Einige in Lucene bereits enthaltene Alternativen zu dem hier verwendeten Directory-Provider sind unter [1] beschrieben.

  3. Als nächstes müssen die Hibernate-Mappings der zu durchsuchenden Entitäten um einige Annotations ergänzt werden:
    1. Die Entity-Klasse muss zunächst um die Annotation
      @Indexed

      ergänzt werden. Optional kann hier noch mit

      @Analyzer(impl = MyAnalyzer.class)

      eine eigene Analyzer-Klasse angegeben werden, da der voreingestellte StandardAnalyzer nur für die Suche in englischen Texten optimiert ist und sich mit einem an die jeweilige Sprache angepaßten Analyzer bessere Ergebnisse erzielen lassen. Weitere Analyzer (u.A. für deutschsprachige Texte) findet man unter [2].

    2. Die zu durchsuchenden Felder werden mit
      @Field(index=Index.TOKENIZED)

      annotiert, wobei Index.TOKENIZED bedeutet, dass der Inhalt des Feldes beim Anlegen des Index in Tokens zerlegt wird. Dies ist nötig, damit später innerhalb des Feldes nach einzelnen Worten gesucht werden kann.

    3. Für jede Entität muss genau ein Feld mit der Annotation
      @DocumentId

      als Id für Lucene markiert werden. Dieses Feld muss ein Objekt innerhalb des gesamten Index eindeutig identifizieren (auch gegenüber Objekten andere Entity-Klassen).

Als nächstes muß der aktuelle Bestand erstmalig indexiert werden. Für eine vorhandene Hibernate Session mySession und eine Liste myArticles von Artikel-Objekten könnte das wie folgt geschehen:

FullTextSession fullTextSession = Search.createFullTextSession(mySession);
Transaction tx = fullTextSession.beginTransaction();
for (Article article : myArticles) {
   fullTextSession.index(article);
}
tx.commit();

Nachdem dies abgeschlossen ist, können Volltextsuchen durchgeführt werden. Neue Objekte werden zukünftig beim Persistieren automatisch in den Index aufgenommen.

Eine Suchanfrage mit vorhandener Hibernate Session mySession in einem Bestand von Artikeln, die ein mit @Field annotiertes Feld textContent haben, könnte dann wie folgt aussehen:

String searchTerm = "Hibernate";
FullTextSession fullTextSession = Search.createFullTextSession( mySession );
Transaction tx = fullTextSession.beginTransaction();
QueryParser parser = new QueryParser("textContent", new StandardAnalyzer());
org.apache.lucene.search.Query query = parser.parse( searchTerm );
org.hibernate.Query hibQuery = fullTextSession.createFullTextQuery( query, Article.class );
List result = hibQuery.list();
tx.commit();
session.close();

Im Anschluß befinden sich in result alle Artikel, deren textContent das Wort Hibernate enthält. Neben der exakten Suche gibt es noch viele weitere Suchmöglichkeiten (z.B. mit Wildcards und unscharfe Suchen). Mehr zu diesem Thema findet man unter [3].


Malte Wulf