JavaFX でダイアグラムを書く

f:id:Naotsugu:20200308011022p:plain


はじめに

前回は JavaFX の環境構築についてのポストでした。

blog1.mammb.com

今回は、JavaFX により、上記のような簡単なダイアグラムを表示するまでを、チュートリアル形式で説明します。


マウスドラッグ可能なダイアグラムの作成

最初に箱だけのダイアグラムを表示し、マウスでドラックできるようにしてみましょう。

前回の記事の内容に続いて、Diagram を以下のように作成します。

package example.javafx;

import javafx.geometry.Bounds;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.layout.Pane;

public class Diagram extends Pane {

    public Diagram(Node node) {
        super(node);
        enableDrag();
    }

    private void enableDrag() {

        final Delta dragDelta = new Delta();

        setOnMousePressed(e -> {
            dragDelta.x = getBoundsInParent().getMinX() - e.getScreenX();
            dragDelta.y = getBoundsInParent().getMinY() - e.getScreenY();
            getScene().setCursor(Cursor.MOVE);
        });

        setOnMouseDragged(e -> {
            Bounds bounds = getBoundsInParent();
            double newX = e.getScreenX() + dragDelta.x;
            if (newX > 0 && newX + bounds.getWidth() < getScene().getWidth()) {
                setLayoutX(newX);
            }
            double newY = e.getScreenY() + dragDelta.y;
            if (newY > 0 && newY + bounds.getHeight() < getScene().getHeight()) {
                setLayoutY(newY);
            }
        });

        setOnMouseReleased(e -> getScene().setCursor(Cursor.HAND));

        setOnMouseEntered(e -> {
            if (!e.isPrimaryButtonDown()) {
                getScene().setCursor(Cursor.HAND);
            }
        });

        setOnMouseExited(e -> {
            if (!e.isPrimaryButtonDown()) {
                getScene().setCursor(Cursor.DEFAULT);
            }
        });
    }

    static class Delta { double x, y; }

}

Diagram の中身に表示するコンテンツは別途作成するものとして、ダイアグラムを入れ物として Pane を継承して作成しました。

setOnMousePressed() では、マウスのボタンが押された時の操作を定義しています。ドラッグ開始の起点を Delta として記録し、マウスカーソルを変更しているだけの処理になります。

setOnMouseDragged() では、ドラッグ操作時に Diagram の位置を変更する処理を定義しています。画面からはみ出さない範囲で Diagram の表示位置を変更しています。

その他はマウスカーソルの表示を処理しているだけです。


最初のダイアグラムの表示

作成した Diagram を表示してみましょう。

App クラスを以下のように変更します。

package example.javafx;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.stage.Stage;

public class App extends Application {

    @Override
    public void start(Stage stage) {

        Pane canvas = new Pane();
        canvas.setStyle("-fx-background-color: #2e3032;");

        Diagram d = createDiagram("Diagram");
        canvas.getChildren().add(d);

        Scene scene = new Scene(canvas, 320, 240);
        stage.setScene(scene);
        stage.show();
    }
    
    private Diagram createDiagram(String name) {
        Label label = new Label(name);
        label.setFont(new Font(18));
        label.setTextFill(Color.WHITESMOKE);

        Diagram d = new Diagram(label);
        d.setStyle(
                "-fx-background-color: #424242;" +
                "-fx-border-color: #6d6d6d;" +
                "-fx-border-width: 2;");
        return d;
    }

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

createDiagram() にてダイアグラムに Lable を追加したものを生成し、canvas に追加しています。

早速実行してみましょう。

$ ./gradlew run

以下のように初期のダイアグラムが表示されます。

f:id:Naotsugu:20200308011046p:plain

ダイアグラムはマウスでドラッグで位置を変更することができます。


ダイアグラムを繋ぐコネクタの作成

続いて、ダイアグラムをコネクタで連結できるようにしていきます。

最初に、コネクタの始点と終点を表す Anchor を作成します。

package example.javafx;

import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;

public abstract class Anchor {

    protected ReadOnlyDoubleWrapper x = new ReadOnlyDoubleWrapper();
    protected ReadOnlyDoubleWrapper y = new ReadOnlyDoubleWrapper();

    protected Anchor(Node node) {
        calcCenter(node.getBoundsInParent());
        node.boundsInParentProperty().addListener(
                (observableValue, oldBounds, bounds) -> calcCenter(bounds));
    }

    public static Anchor topOf(Node node) {
        return new TopAnchor(node);
    }
    public static Anchor rightOf(Node node) {
        return new RightAnchor(node);
    }
    public static Anchor bottomOf(Node node) {
        return new BottomAnchor(node);
    }
    public static Anchor leftOf(Node node) {
        return new LeftAnchor(node);
    }

    public double distanceTo(Anchor other) {
        double dx = other.x.getReadOnlyProperty().doubleValue() - 
            this.x.getReadOnlyProperty().doubleValue();
        double dy = other.y.getReadOnlyProperty().doubleValue() -
            this.y.getReadOnlyProperty().doubleValue();
        return Math.sqrt(dx * dx + dy * dy);
    }

    public ReadOnlyDoubleProperty xProperty() {
        return x.getReadOnlyProperty();
    }

    public ReadOnlyDoubleProperty yProperty() {
        return y.getReadOnlyProperty();
    }

    protected abstract void calcCenter(Bounds bounds);

    public abstract Point2D auxiliaryPoint();


    static class TopAnchor extends Anchor {
        TopAnchor(Node node) {
            super(node);
        }
        protected void calcCenter(Bounds bounds) {
            x.set(bounds.getCenterX());
            y.set(bounds.getMinY());
        }
        public Point2D auxiliaryPoint() {
            return new Point2D(
                    x.getReadOnlyProperty().doubleValue(),
                    y.getReadOnlyProperty().doubleValue() - 50);
        }
    }

    static class RightAnchor extends Anchor {
        RightAnchor(Node node) {
            super(node);
        }
        protected void calcCenter(Bounds bounds) {
            x.set(bounds.getMaxX());
            y.set(bounds.getCenterY());
        }
        public Point2D auxiliaryPoint() {
            return new Point2D(
                    x.getReadOnlyProperty().doubleValue() + 50,
                    y.getReadOnlyProperty().doubleValue());
        }
    }

    static class BottomAnchor extends Anchor {
        BottomAnchor(Node node) {
            super(node);
        }
        protected void calcCenter(Bounds bounds) {
            x.set(bounds.getCenterX());
            y.set(bounds.getMaxY());
        }
        public Point2D auxiliaryPoint() {
            return new Point2D(
                    x.getReadOnlyProperty().doubleValue(),
                    y.getReadOnlyProperty().doubleValue() + 50);
        }
    }

    static class LeftAnchor extends Anchor {
        LeftAnchor(Node node) {
            super(node);
        }
        protected void calcCenter(Bounds bounds) {
            x.set(bounds.getMinX());
            y.set(bounds.getCenterY());
        }
        public Point2D auxiliaryPoint() {
            return new Point2D(
                    x.getReadOnlyProperty().doubleValue() - 50,
                    y.getReadOnlyProperty().doubleValue());
        }
    }
}

Anchor は、ダイアログの上下左右のいずれかに存在するものとして各 Anchor を生成できるようにしました。

Diagram に作成した Anchor を追加します。

public class Diagram extends Pane {

    private List<Anchor> anchors;

    public Diagram(Node node) {
        super(node);
        this.anchors = Arrays.asList(
                Anchor.topOf(this),
                Anchor.rightOf(this),
                Anchor.bottomOf(this),
                Anchor.leftOf(this));
        enableDrag();
    }

    public List<Anchor> anchors() {
        return anchors;
    }
    // ...
}

続いて Anchor 同士を繋ぐ Connector を作成します。

package example.javafx;

import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.paint.Color;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.transform.Rotate;

public class Connector extends Group {

    private Diagram from;
    private Diagram to;
    private CubicCurve curve;
    private Arrow arrow;

    public Connector(Diagram fromDiagram, Diagram toDiagram) {

        from = fromDiagram;
        to = toDiagram;
        curve = new CubicCurve();
        curve.setStroke(Color.FORESTGREEN);
        curve.setStrokeWidth(2);
        curve.setStrokeLineCap(StrokeLineCap.ROUND);
        curve.setFill(null);
        arrow = new Arrow();
        getChildren().addAll(curve, arrow);

        calculate();

        from.boundsInParentProperty().addListener(
                (observableValue, oldBounds, bounds) -> calculate());
        to.boundsInParentProperty().addListener(
                (observableValue, oldBounds, bounds) -> calculate());

    }

    protected void calculate() {

        Anchor start = null;
        Anchor end   = null;
        double distance = Double.MAX_VALUE;

        for (Anchor fromAnchor : from.anchors()) {
            for (Anchor toAnchor : to.anchors()) {
                double len = fromAnchor.distanceTo(toAnchor);
                if (len <= distance) {
                    distance = len;
                    start = fromAnchor;
                    end = toAnchor;
                }
            }
        }

        curve.setStartX(start.xProperty().doubleValue());
        curve.setStartY(start.yProperty().doubleValue());
        curve.setControlX1(start.auxiliaryPoint().getX());
        curve.setControlY1(start.auxiliaryPoint().getY());

        curve.setEndX(end.xProperty().doubleValue());
        curve.setEndY(end.yProperty().doubleValue());
        curve.setControlX2(end.auxiliaryPoint().getX());
        curve.setControlY2(end.auxiliaryPoint().getY());

        arrow.calc(curve, 1);
    }


    class Arrow extends Polygon {

        private Rotate rz;

        public Arrow() {
            super(new double[] { 0, 0, 5, 10, -5, 10 });
            setFill(Color.FORESTGREEN);
            rz = new Rotate();
            rz.setAxis(Rotate.Z_AXIS);
            getTransforms().addAll(rz);
        }

        public void calc(CubicCurve curve, float t) {

            setTranslateX(curve.getEndX());
            setTranslateY(curve.getEndY());

            double size = Math.max(
                    curve.getBoundsInLocal().getWidth(),
                    curve.getBoundsInLocal().getHeight());
            double scale = size / 4d;
            Point2D tan = evalDt(curve, t).normalize().multiply(scale);
            double angle = Math.atan2(tan.getY(), tan.getX());
            double offset = (t > 0.5) ? +90 : -90;
            rz.setAngle(Math.toDegrees(angle) + offset);
        }

        private Point2D evalDt(CubicCurve c, float t){
            return new Point2D(-3 * Math.pow(1 - t, 2) * c.getStartX() +
                    3 * (Math.pow(1 - t, 2) - 2 * t * (1 - t)) * c.getControlX1() +
                    3 * ((1 - t) * 2 * t - t * t) * c.getControlX2() +
                    3 * Math.pow(t, 2) * c.getEndX(),
                    -3 * Math.pow(1 - t, 2) * c.getStartY() +
                    3 * (Math.pow(1 - t, 2) - 2 * t * (1 - t)) * c.getControlY1() +
                    3 * ((1 - t) * 2 * t - t * t) * c.getControlY2() +
                    3 * Math.pow(t, 2) * c.getEndY());
        }
    }
}

Connector は始点となる Diagram のいずれかの Anchor から、終点となる Diagram のいずれかの Anchor を、CubicCurve で接続するものとしました。

CubicCurve を使うことでベジェ曲線が簡単に作れます。

Anchor は、Anchor 間の距離が最も近いもの同士を選択し、CubicCurve で繋ぐようにしています。

また、Arrow にて Anchor の終点に矢印を表示するようにしました。


App クラスを以下のように修正して実行してみましょう。

public class App extends Application {
    @Override
    public void start(Stage stage) {
        // ...

        Diagram d1 = createDiagram("Diagram1");
        Diagram d2 = createDiagram("Diagram2");

        canvas.getChildren().addAll(d1, d2, new Connector(d1, d2));
        // ...
    }
}

以下のように Diagram をコネクタ接続することができました。

f:id:Naotsugu:20200308011114p:plain


クラス図の表示

大枠は完成したので、クラス図を書いてみましょう。

App クラスを以下のように書き換えます。

package example.javafx;

import javafx.application.Application;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class App extends Application {

    @Override
    public void start(Stage stage) {

        Pane canvas = new Pane();
        canvas.setPrefSize(680, 500);
        canvas.setStyle("-fx-background-color: #2e3032;");

        Diagram d1 = createDiagram("Employee", "id: Long", "name: String", "salary: Money");
        Diagram d2 = createDiagram("Department", "id: Long", "name: String");
        Diagram d3 = createDiagram("Address", "id: Long", "code: String", "street: String", "city: String", "state: String");
        Diagram d4 = createDiagram("Project", "id: Long", "name: String");

        canvas.getChildren().addAll(d1, d2, d3, d4,
                new Connector(d1, d2),
                new Connector(d2, d3),
                new Connector(d1, d4));

        Scene scene = new Scene(canvas);
        stage.setScene(scene);
        stage.show();

        liningUp(canvas.getBoundsInLocal(), d1, d2, d3, d4);
    }

    private Diagram createDiagram(String name, String... props) {

        VBox vbox = new VBox();
        vbox.setStyle(
                "-fx-border-color: #6d6d6d;" +
                "-fx-border-width: 2;");

        HBox header = createRow(name);
        header.setStyle(
                "-fx-background-color: #424242;" +
                "-fx-border-width: 0 0 1 0;" +
                "-fx-border-color: #6d6d6d;");
        vbox.getChildren().add(header);

        for (String prop : props) {
            vbox.getChildren().add(createRow(prop));
        }

        return new Diagram(vbox);
    }

    private HBox createRow(String name) {

        Text text = new Text(name);
        text.setFont(new Font(18));
        text.setFill(Color.web("#a9b7c6"));

        HBox hbox = new HBox(5);
        hbox.setPadding(new Insets(4, 10, 4, 10));
        hbox.setStyle("-fx-background-color: #2e3032;");
        hbox.getChildren().add(text);

        return hbox;
    }

    private void liningUp(Bounds canvas, Node... nodes) {
        float margin = 50;
        double rowHeight = 0;
        double x = margin, y = margin;
        for (Node node : nodes) {
            if (x + node.getBoundsInLocal().getWidth() > canvas.getWidth()) {
                x = margin;
                y += rowHeight + margin + margin;
            }
            node.relocate(x, y);
            if (node.getBoundsInLocal().getHeight() > rowHeight) {
                rowHeight = node.getBoundsInLocal().getHeight();
            }

            x += node.getBoundsInLocal().getWidth() + margin + margin;
        }
    }

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

少し長くなりましたが、やっていることは createDiagram()Diagram を生成してコネクタの接続を定義しているだけです。

ダイアグラムを作成したら liningUp() にてダイアグラムの初期位置を整列しています。

実行すると以下のようにクラス図っぽいダイアグラムが表示されます。

f:id:Naotsugu:20200308010854g:plain


まとめ

JavaFX で簡単なダイアグラムを表示してみました。

少し拡張すれば、クラスファイルからクラス図を表示したり、DDL から ER図を生成したりといったことも比較的簡単に実現できます。

JDK に同梱されなくなった JavaFX ではありますが、UI ライブラリとしても成熟しており、また触っていて楽しいので、みなさんも触ってみてはいかがでしょうか?