概要
前回は画像を読み込んでキャンバスに表示するところまでを実装したので、今回は画像の平行移動・ズーム機能を実装していく。開発環境やディレクトリ構成は前記事と同じなので省略。
完成イメージ
- マウス右ドラッグで画像を平行移動する
- マウスホイール操作で画像をズームする
実装
拡大縮小処理
Viewの平行移動処理はsetEventsToView()
というメソッドを定義して記述していくことにする。まず、ngOnInit()
からsetEventsToView()
を呼び出せるようにしておく。
ngOnInit(): void { paper.setup(this.canvas.nativeElement); this.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
型のオブジェクトA
とB
についてA.subtract(B)
は、A.x - B.x
とA.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
という列挙型でまとめておく。また、現在のマウスポインタの座標currentX
とcurrentX
、現在の倍率を画面上に表示するための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; // 追加 // 省略 }
クライアント領域(ウインドウ枠内の領域)基準で「マウスポインタの座標 - キャンバスの左上の座標」を計算すればキャンバス領域基準のマウスポインタの座標を取得できる。取得したx, y座標をそれぞれcurrentX
とcurrentX
に格納する。ここは、AngularとPaper.jsでパス描画ツールを作る1で書いたのと内容は同じになる(当時のメソッド名はgetCurrentPosision
にしてしまったが、、、currentX
とcurrentY
に座標値を設定するから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; }
キャンバス上でマウスホイールの操作によってズーム処理をしたいのだが、デフォルトだと画面のスクロールになってしまう。そこで、最初に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
でも良い。その場合は拡大処理と縮小処理の記述が全て逆になる。
さらに、倍率がminScale
、maxScale
付近の時はもう少し厳密に考える必要がある。倍率に1 / factor
あるいはfactor
を掛け続けた値が必ずしもminScale
、maxScale
と一致するとは限らない(むしろ一致しないことの方がほとんどだと思う)からだ。例えば、factor = 1.25
でmaxScale = 3
として拡大していく場合、wheelDeltaY = 4
(マウスホイールを4回操作)で倍率が2.441となるが、後1回マウスホイールを操作すると倍率が3.052となり最大値3を超えてしまう。したがって、倍率が2.441から3になるためのfactor
を逆算し、そのfactor
をViewの縮小計算に適用してやる必要がある。この時のfactor
をF
とすれば、2.441 x F = 3
をF
について解きF ≒ 1.229
となるのでこれを適用してやればよい。縮小の場合も同じで、minScale = 0.5
である場合はwheelDeltaY = -4
で倍率が最小値0.5を下回ってしまうので、0.512 x 1 / F = 0.5
をF
について解いた結果のF ≒ 1.024
を適用してやればよい。
始めに、this.currentScale === ScaleConfig.minScale
であればこれ以上縮小させないのでここで処理を終了する。
さらに、現在のスケールが上限値である状態から1段階縮小する時(上図の折れ線グラフの赤い区間を右方向に進む時)だけはfactor
がF
で、それ以外の時は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; }
拡大処理のフローも縮小処理とほぼ同じで、maxScale
とminScale
を入れ替えること、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>
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のサンプル等を探しても一発でコレというものが見つからなかったので、割とごりごり実装しなければならなかった。ズーム処理というものは結構当たり前のもののように思えるが実装は意外と骨だった。 次回は、キャンバス上に点をプロットする前の準備として、座標軸の設定などをできるようにする。
参考