プロジェクト

全般

プロフィール

JavaFX Canvasを使ったベクター描画と拡大縮小移動操作

改訂版 Canvasの表示中心を維持したまま拡大縮小するようにした

はじめに

JavaFXでは、2次元のベクターグラフィックスAPIが用意されており、ベクターで図形を描画し、拡大縮小、平行移動、回転、スキューなどのアフィン変換によるグラフィックス描画を行うことができます。

今回は、JavaFXのCanvasクラスを使ってベクターを描画し、マウスのホイール操作で拡大縮小、マウスのドラッグ操作で平行移動をするプログラムを作ります。

画面のイメージ(初版)は次になります。

screenshot-1.png

プログラム構成

開発環境

Java SE Development Kit 8、NetBeans IDE 8.2、SceneBuilder 8.2.0で作成しました。

ソースコード構成

次のリポジトリに格納しています。
source:learn/java/javafx/ZoomPanCanvas

NetBeansでJavaFXアプリケーション(FXML)を生成したときのファイル構成のままで、ファイルに内容を追加しています。

ZoomPanCanvas
  +-- nbproject
  +-- src
  |     +-- zoompancanvas
  |           +-- ZoomPanCanvas.fxml
  |           +-- ZoomPanCanvas.java
  |           +-- ZoomPanCanvasController.java
  +-- build.xml
  +-- manifest.mf

FXMLでの画面定義

scenebuilder-1.png

NetBeans IDE 8.01のJavaFX(FXML)アプリケーションの雛形が生成したfxmlファイルに少しだけ手を入れました。手を入れた内容は、雛形で生成された画面に存在したボタンとラベルの位置を移動し、新たにCanvasを貼ったことです。

1 本プログラムの初版作成時に使用したNetBeansバージョン

アプリケーションクラス

source:learn/java/javafx/ZoomPanCanvas/src/zoompancanvas/ZoomPanCanvas.java は、手を入れていません。

コントローラクラス

source:learn/java/javafx/ZoomPanCanvas/src/zoompancanvas/ZoomPanCanvasController.java にほとんどの処理を記述しました。

Canvasへの描画処理

drawCanvasメソッドを定義し、ここでCanvasへの描画をしています。

    private static final double RADIUS = 10_000f;
    @FXML
    private Canvas canvas;
    private Point2D originTranslate = new Point2D(0, 0);
    :
    private void drawCanvas() {
        GraphicsContext gc = canvas.getGraphicsContext2D();
        clearCanvas();
        gc.setTransform(getZoomPanTransform(scale.get(), originTranslate)); // (4)
        gc.setStroke(Color.DARKBLUE);
        gc.setLineWidth(1 / scale.get());
        gc.strokeLine(-RADIUS, 0, RADIUS, 0); // (1)
        gc.strokeLine(0, -RADIUS, 0, RADIUS); // (2)
        for (double d = RADIUS / 20; d < RADIUS; d += RADIUS / 20) {
            gc.strokeOval(-d, -d, 2 * d, 2 * d); // (3)
        }
    }
  • Canvasへの描画は、GraphicsContextをcanvasから取得し、それが持つメソッドを呼んで行います。
    • (1) データ座標系で座標(-10000, 0)と(10000, 0)を結ぶ直線を描画
    • (2) データ座標系で座標(0, -10000)と(0, 10000)を結ぶ直線を描画
    • (3) データ座標系で座標(0, 0)を中心に半径500, 1000, 1500, 2000, ... の円を描画
  • データ座標系から画面座標系への変換(拡大/縮小、平行移動)は、Affine変換(Transformオブジェクト)を設定して行います。
    • (4) 後述するgetZoomPanTransformメソッドに拡大率、平行移動量を指定してTransformオブジェクトを生成して設定
  • 描画する線の色は、setStrokeで指定します。
  • 描画する線の幅は、setLineWidthで指定します。
    • 線の幅も(4)の拡大率によって増減してしまうので、拡大率によらず同じ線幅で描画するため、拡大率で割った値を線幅に設定
      線幅 = 1 / 拡大率 とすることで、実際の線幅 = 線幅 * 拡大率 = 1 / 拡大率 * 拡大率 = 1 となる

座標変換(transform)の設定

    private Affine getZoomPanTransform(double scale, Point2D translate) {
        Point2D center = getCenterOfCanvas();
        Point2D totalTranslate = translate.add(center);
        return new Affine(scale, 0, totalTranslate.getX(), 0, -scale, totalTranslate.getY());
    }

    private Point2D getCenterOfCanvas() {
        return new Point2D(canvas.getWidth() / 2, canvas.getHeight() / 2);
    }
  • 拡大率、データ座標系の原点から画面座標系の原点への平行移動量を引数で受け取ります。
  • 画面座標系の原点(左上隅)からCanvasの中心への平行移動量を加えてアフィン変換を生成しています。

マウスのホイール操作で拡大縮小処理

    private static final double SCALE_RATIO = 1.4;
    private DoubleProperty scale = new SimpleDoubleProperty(1.0);
        :
    @Override
    public void initialize(URL url, ResourceBundle rb) {
        canvas.setOnScroll(ev -> {
            double prevScaleValue = scale.get();
            double nextScaleValue = (ev.getDeltaY() >= 0) ? scale.get() * SCALE_RATIO : scale.get() / SCALE_RATIO;
            scale.set(nextScaleValue);
            originTranslate = originTranslate.multiply(nextScaleValue / prevScaleValue);
            drawCanvas();
        });
  • 拡大率はラベルや将来的にスライダーで変更する等に備えてJavaFXのプロパティで定義します。
  • canvas上でマウスホイール操作をするとOnScrollイベントが発生するので、setOnScrollメソッドでイベント発生時の処理を設定します。
  • setOnScrollの引数は EventHandler です。これは@FunctionalInterfaceなのでラムダ式で処理を記述することで簡潔なコードになります。
  • ここでは、ホイールの操作方向に応じて拡大率を1.4倍または1/1.4倍しています。
  • 拡大率が変化したのでcanvasの描画を再実行します。

マウスのドラッグ操作で平行移動処理(誤りあり)

    private double translateX;
    private double translateY;
    private Point2D dragStartPoint;
        :

        canvas.setOnMousePressed(ev -> {
            dragStartPoint = new Point2D(ev.getSceneX(), ev.getSceneY()); // (1)
        });
        canvas.setOnMouseDragged(ev -> {
            double deltaX = ev.getSceneX() - dragStartPoint.getX(); // (2)
            double deltaY = ev.getSceneY() - dragStartPoint.getY(); // (2)
            translateX += deltaX; // (3)
            translateY += deltaY; // (3)
            drawCanvas(); // (4)
        });
  • canvas上でマウスのドラッグ操作を開始すると、まずOnMousePressedイベントが発生するので、setOnMousePressedメソッドでマウスが押された座標をデータ座標系で取得しフィールドに記憶します(1)。
  • canvas上でマウスのドラッグ操作が 完了する 継続していると、OnMouseDraggedイベントが随時発生します。setOnMouseDraggedメソッドでこのイベントを捕捉し、setOnMousePressedで記憶した座標とこのイベントの座標の差から移動量を計算します(2)。
  • 移動量を現在の平行移動量に加えます(3)。
  • 平行移動量が変化したのでcanvasの描画を再実行します(4)。
問題点 ドラッグがセンシティブすぎる

この実装で実際にcanvasをドラッグ&ドロップすると、想像以上に移動してしまうといった反応をします。
setOnMousePressedとsetOnMouseDraggedにログを仕掛けてイベントの発生を追うことにします。

        canvas.setOnMousePressed(ev -> {
            :
            logger.info(() -> String.format("OnMousePressed, screenXY=(%f, %f), sceneXY=(%f, %f)",
                    ev.getScreenX(), ev.getScreenY(), ev.getSceneX(), ev.getSceneY()));
        });
        canvas.setOnMouseDragged(ev -> {
            :
            logger.info(() -> String.format("OnMouseDragged, screenXY=(%f, %f), sceneXY=(%f, %f), set translate=(%f, %f)",
                    ev.getScreenX(), ev.getScreenY(), ev.getSceneX(), ev.getSceneY(), translateX, translateY));
        });

実行すると

OnMousePressed, screenXY=(763.000000, 307.000000), sceneXY=(3.000000, 2.000000)
OnMouseDragged, screenXY=(763.000000, 308.000000), sceneXY=(3.000000, 3.000000), set translate=(0.000000, 1.000000)
OnMouseDragged, screenXY=(764.000000, 308.000000), sceneXY=(4.000000, 3.000000), set translate=(1.000000, 2.000000)
OnMouseDragged, screenXY=(765.000000, 308.000000), sceneXY=(5.000000, 3.000000), set translate=(3.000000, 3.000000)
OnMouseDragged, screenXY=(765.000000, 309.000000), sceneXY=(5.000000, 4.000000), set translate=(5.000000, 5.000000)
OnMouseDragged, screenXY=(766.000000, 310.000000), sceneXY=(6.000000, 5.000000), set translate=(8.000000, 8.000000)
OnMouseDragged, screenXY=(766.000000, 311.000000), sceneXY=(6.000000, 6.000000), set translate=(11.000000, 12.000000)
OnMouseDragged, screenXY=(767.000000, 311.000000), sceneXY=(7.000000, 6.000000), set translate=(15.000000, 16.000000)
OnMouseDragged, screenXY=(767.000000, 312.000000), sceneXY=(7.000000, 7.000000), set translate=(19.000000, 21.000000)

と連続して発生しています。
  • MouseEventのgetScreenX/getScreenYメソッドは、ディスプレイ上のマウスの絶対位置を返却する
  • MouseEventのgetSceneX/getSceneYメソッドは、ここではcancas上の左上を(0, 0)としたマウスの画面座標系を返却する
ここで、問題点が見えてきました
  • 随時発生するOnMouseDraggedイベントについて、毎回OnMousePressされた座標とOnMouseDraggedイベントの座標との差を平行移動量に足しこんでいる点(前回のOnMouseDraggedで平行移動量に加えた量が今回のOnMouseDraggedで再び加えられてしまっている)

マウスのドラッグ操作で平行移動処理(誤り修正版)

    private Point2D originTranslate = new Point2D(0, 0);
    private Point2D prevDragPoint;
        :
        canvas.setOnMousePressed(ev -> {
            prevDragPoint = new Point2D(ev.getSceneX(), ev.getSceneY());
        });
        canvas.setOnMouseDragged(ev -> {
            Point2D dragPoint = new Point2D(ev.getSceneX(), ev.getSceneY());
            originTranslate = originTranslate.add(dragPoint.subtract(prevDragPoint));
            prevDragPoint = dragPoint;
            drawCanvas();
        });
  • マウスのドラッグ開始時のOnMousePressイベントで、マウス座標をprevDragPointに設定
  • 続くマウスのドラッグ中のOnMouseDraggedイベントで、取得したマウス座標を直前のイベントのマウス座標で引き、それを現在の平行移動量に加算
  • 取得したマウス座標を次のOnMouseDraggedイベントで前回値として使うためprevDiagPointに保存

という処理です。

画面のクリア

    private static final Affine IDENTITY_TRANSFORM = new Affine(1f, 0f, 0f, 0f, 1f, 0);
    :
    private void clearCanvas() {
        GraphicsContext gc = canvas.getGraphicsContext2D();
        gc.setTransform(IDENTITY_TRANSFORM);
        gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
    }
  • Canvasに描画した内容をクリアする方法が分からなかったので、ここでは画面表示領域をクリアしています。
  • 画面領域を指定するので拡大率・平行移動量をなし(恒等変換)にしてからclearRectを呼んでいます。


7年以上前に更新