HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

AngularとPaper.jsでデジタイザを作る2

概要

前回は画像を読み込んでキャンバスに表示するところまでを実装したので、今回は画像の平行移動・ズーム機能を実装していく。開発環境やディレクトリ構成は前記事と同じなので省略。

完成イメージ

  • マウス右ドラッグで画像を平行移動する
  • マウスホイール操作で画像をズームする

実装

拡大縮小処理

Viewの平行移動処理はsetEventsToView()というメソッドを定義して記述していくことにする。まず、ngOnInit()からsetEventsToView()を呼び出せるようにしておく。

  ngOnInit(): void {
    paper.setup(this.canvas.nativeElement);
    this.setEventsToView(); // 追加
  }
setEventsToView()

始めにtool = new Tool()Toolオブジェクトを生成しておく。Toolオブジェクトはマウスとキーボード操作を介して任意の処理を参照することが出来る。今回はマウスのドラッグとViewの平行移動処理を紐付ける。 tool.onMouseDragでドラッグイベントと平行移動処理を紐付けている。ファイルが読み込まれていない場合は処理を行わずに抜ける。 event.downPoint.subtract(event.point)ではViewを移動させるべき距離を算出している。Point.subtract()は、あるPointオブジェクトのx, y座標に対して引数で指定したPointオブジェクトのx, y座標を減算して得られる新しいPointオブジェクトを返す。例えば、Point型のオブジェクトABについてA.subtract(B)は、
A.x - B.xA.y - B.yの値をそれぞれx, y座標とするPointオブジェクトを返す。event.downPointでドラッグイベントを開始した位置が取得でき、event.pointで現在のマウスポインタの位置が取得できるので、それらの差分をdeltaとする。 最後に、view.scrollBy(delta)とすればdelta.x分だけ水平方向に、delta.y分だけ鉛直方向にViewを平行移動することが出来る。

  private setEventsToView(): void {
    const tool = new Tool();
    tool.onMouseDrag = (event) => {
      if (!this.file) { return; }
      // delta = 最後にクリックされた位置の座標 - 現在地の座標
      const delta = event.downPoint.subtract(event.point);
      view.scrollBy(delta);
    };
  }

ズーム処理 その1(準備)

コンポーネントのクラスファイルの方には、これから必要となる変数などを記述していく。まず、ズーム処理で使用する各種パラメータを定義していこう。倍率に乗じていくファクターfactor(1より大きい任意の値)、倍率の最小値minScale、倍率の最大値maxScaleを定義し、ScaleConfigという列挙型でまとめておく。また、現在のマウスポインタの座標currentXcurrentX、現在の倍率を画面上に表示するためのscale、現在のファクターcurrentFactorを定義しておく。

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import * as paper from 'paper';
import { Raster, Matrix, Path } from 'paper';
import { view, Tool, Point, Layer } from 'paper/dist/paper-full';

// ↓追加
enum ScaleConfig {
  factor = 1.05,
  minScale = 0.5,
  maxScale = 3,
}

@Component({
  selector: 'app-digitizer',
  templateUrl: './digitizer.component.html',
  styleUrls: ['./digitizer.component.scss']
})
export class DigitizerComponent implements OnInit {
  @ViewChild('canvas', { static: true }) canvas: ElementRef<HTMLCanvasElement>;
  @ViewChild('fileInput', { static: false }) fileInput: ElementRef;

  file: File = null;
  imgSrc: string | ArrayBuffer = '';
  currentX = 0; // 追加
  currentY = 0; // 追加
  scale = 1; // 追加
  currentFactor = ScaleConfig.factor; // 追加

  // 各種フラグ
  isScaleEndRange = false; // 追加
    
    // 省略

}
setCurrentPosision()

クライアント領域(ウインドウ枠内の領域)基準で「マウスポインタの座標 - キャンバスの左上の座標」を計算すればキャンバス領域基準のマウスポインタの座標を取得できる。取得したx, y座標をそれぞれcurrentXcurrentXに格納する。ここは、AngularとPaper.jsでパス描画ツールを作る1で書いたのと内容は同じになる(当時のメソッド名はgetCurrentPosisionにしてしまったが、、、currentXcurrentYに座標値を設定するからsetのほうがよさそう)。

  setCurrentPosision(event): void {
    const rect = event.target.getBoundingClientRect();
    this.currentX = view.viewToProject(event.clientX - rect.left).x;
    this.currentY = view.viewToProject(event.clientY - rect.top).y;
  }
scalingView()

キャンバス上でマウスホイールの操作によってズーム処理をしたいのだが、デフォルトだと画面のスクロールになってしまう。そこで、最初にpreventDefault()で画面のスクロール処理を無効化しておく。また、平行移動の時と同じようにファイルが読み込まれていない場合は処理を行わずに抜ける。
始めに、ズームの中心点(マウスホイール操作を開始した点)の座標でPointerオブジェクトを生成しcursorPointに格納する。 その後は、拡大縮小するためのメソッドzoomUp()zoomOut()cursorPointを渡していくことになる。今回はマウスホイールを上に回転(event.wheelDeltaYが負)して縮小、下に回転(event.wheelDeltaYが正)して拡大できるようにしたいので、event.wheelDeltaYの正負に応じて呼び出すメソッドを切り替える。 zoomUp()zoomOut()の具体的な中身については次節で述べる。 現時点のViewの表示倍率はview.zoomで取得できる(初期のview.zoomは1)ので、ズーム処理後にview.zoomの値で画面表示用のscaleを更新しておく。

  scalingView(event): void {
    event.preventDefault();
    if (!this.file) { return; }
    const cursorPoint = new Point(this.currentX, this.currentY);

    if (event.wheelDeltaY > 0) {
      this.zoomUp(cursorPoint); // 拡大
    } else {
      this.zoomOut(cursorPoint); // 縮小
    }
      this.scale = view.zoom;
  }


次にhtmlファイルの方を編集してイベントを紐付けていく。平行移動処理と同様にズーム処理もViewに対するイベントなので、Toolオブジェクトにイベントを紐付けることが出来ればいいのだが、残念ながらToolオブジェクトにはマウスホイール操作のイベントハンドラが用意されていない。したがって、htmlのcanvas要素にmousewheelハンドラを利用してイベントを紐づけてやる必要がある。scalingView()mousewheelに紐づけよう。scalingView()の引数にはイベントオブジェクトを渡す。また、マウスが移動する時にカーソルの現在位置を更新していくので、setCurrentPosision()mousemoveに紐づける。
さらに、カーソルの現在位置の座標値や倍率をキャンバスの下部に表示しよう。表示値はnumberパイプを適用して小数点以下2桁表示にしている。なお、倍率については上・下限値の場合が分かりやすいように値を着色する。scale<span>タグで囲い、isScaleEndRangeがtrueの時にoutrangeクラスが付いてスタイルを変えられるようにする。今回は文字を赤くしている。

<canvas class="m-0" #canvas (mousewheel)="scalingView($event)" (mousemove)="setCurrentPosision($event)"></canvas>
カーソル座標:{{ currentX | number: '1.2-2' }}, {{ currentY | number: '1.2-2' }}
倍率:<span [class.outrange]="isScaleEndRange">x{{ scale | number: '1.2-2' }}</span>
.canvas-wrapper {
  canvas {
    border: solid 1px #979797;
    width: 700px;
    height: 500px;
    &:hover {
      cursor: crosshair;
    }
  }
  .outrange {
    color: #ff0000;
  }
}

ズーム処理 その2(メイン部分)

縮小の時はマウスホイールを1回操作(wheelDeltaY = -1分)する度に倍率を1 / factor倍する処理を繰り返し、view.zoom === minScaleとなった時点でそれ以上縮小出来ないようにする。拡大の時はマウスホイールを1回操作(wheelDeltaY = 1分)する度に倍率をfactor倍する処理を繰り返し、view.zoom === maxScaleとなった時点でそれ以上拡大出来ないようにする。なお、ここではfactor > 1で考えているがfactor < 1でも良い。その場合は拡大処理と縮小処理の記述が全て逆になる。

さらに、倍率がminScalemaxScale付近の時はもう少し厳密に考える必要がある。倍率に1 / factorあるいはfactorを掛け続けた値が必ずしもminScalemaxScaleと一致するとは限らない(むしろ一致しないことの方がほとんどだと思う)からだ。例えば、factor = 1.25maxScale = 3として拡大していく場合、wheelDeltaY = 4(マウスホイールを4回操作)で倍率が2.441となるが、後1回マウスホイールを操作すると倍率が3.052となり最大値3を超えてしまう。したがって、倍率が2.441から3になるためのfactorを逆算し、そのfactorをViewの縮小計算に適用してやる必要がある。この時のfactorFとすれば、2.441 x F = 3Fについて解きF ≒ 1.229となるのでこれを適用してやればよい。縮小の場合も同じで、minScale = 0.5である場合はwheelDeltaY = -4で倍率が最小値0.5を下回ってしまうので、0.512 x 1 / F = 0.5Fについて解いた結果のF ≒ 1.024を適用してやればよい。

zoomOut()

始めに、this.currentScale === ScaleConfig.minScaleであればこれ以上縮小させないのでここで処理を終了する。 さらに、現在のスケールが上限値である状態から1段階縮小する時(上図の折れ線グラフの赤い区間を右方向に進む時)だけはfactorFで、それ以外の時はfactorがデフォルト値でなければならないので、this.currentScale !== ScaleConfig.maxScaleの時だけthis.currentFactorをデフォルト値ScaleConfig.factorに戻すようにしている。
次に、縮小後のスケールが下限値を超える場合だけ前述のfactorの逆算を行う。 Viewの拡大縮小処理にはview.matrixに対してscale()を利用する。scale()の引数にfactorの逆数と縮小の中心座標を渡せばそれに応じた縮小処理を行える。
一応これで縮小処理自体は完了だが、最後にisScaleEndRangeの真偽を判定して値を更新すれば、スケールが下限値の時に値が赤く着色されるので分かりやすくなる。

  zoomOut(cursorPoint?: Point): void {
    if (view.zoom === ScaleConfig.minScale) { return; }
    if (view.zoom !== ScaleConfig.maxScale) {
      this.currentFactor = ScaleConfig.factor;
    }
    const nextScale = view.zoom / this.currentFactor;
    // scaleの下限値より小さくなってしまう場合は、scaleが下限値になるfactorを逆算する
    if (nextScale < ScaleConfig.minScale) {
      this.currentFactor = view.zoom / ScaleConfig.minScale;
    }

    // 縮小処理を実行
    view.matrix.scale(1 / this.currentFactor, cursorPoint || view.center);
    this.isScaleEndRange = view.zoom === ScaleConfig.minScale;
  }
zoomUp()

拡大処理のフローも縮小処理とほぼ同じで、maxScaleminScaleを入れ替えること、view.matrix.scale()の引数にfactorを渡すことぐらいの違いしかない。

  zoomUp(cursorPoint?: Point): void {
    if (view.zoom === ScaleConfig.maxScale) { return; }
    if (view.zoom !== ScaleConfig.minScale) {
      this.currentFactor = ScaleConfig.factor;
    }
    const nextScale = view.zoom * this.currentFactor;
    // scaleの上限値より大きくなってしまう場合は、scaleが上限値になるfactorを逆算する
    if (nextScale > ScaleConfig.maxScale) {
      this.currentFactor = ScaleConfig.maxScale / view.zoom;
    }

    // 拡大処理を実行
    view.matrix.scale(this.currentFactor, cursorPoint || view.center);
    this.isScaleEndRange = view.zoom === ScaleConfig.maxScale;
  }


座標変換行列についての詳細は別途記事にする予定。

Viewのリセット

最後に、Viewの平行移動やズームの状態を初期状態にリセットできるボタンを追加しよう。 とりあえず「画像を読み込む」ボタンの隣に置くことにする。画像が読み込まれていない場合は平行移動・ズーム処理は行わないのでボタンは非活性になるようにする。

<div class="d-flex image-configure mt-2">
  <div class="col-8 p-0">
    <button mat-raised-button (click)="onClickFileInputButton()">画像を読み込む</button>
    <button mat-raised-button class="ml-2" [disabled]="!file" (click)="resetViewConfig()">Viewをリセット</button>
  </div>
</div>
resetViewConfig()

view.matrix.reset()を使うと、Viewのすべての状態を元に戻す(座標変換が行われていない状態)ことができる。 後は、拡大縮小・ズームで使っていた各種パラメータ等を元に戻してやれば良い。

  resetViewConfig(): void {
    view.matrix.reset();
    this.scale = view.zoom;
    this.currentFactor = ScaleConfig.factor;
    this.currentX = this.currentY = 0;
    this.isScaleEndRange = false;
  }

おわりに

画像の平行移動処理は比較的簡単にできたものの、ズーム処理はPaper.jsのサンプル等を探しても一発でコレというものが見つからなかったので、割とごりごり実装しなければならなかった。ズーム処理というものは結構当たり前のもののように思えるが実装は意外と骨だった。 次回は、キャンバス上に点をプロットする前の準備として、座標軸の設定などをできるようにする。


参考