HAKUTAI Tech Notes

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

AngularとPaper.jsでパス描画ツールを作る1

Angularを利用してパス描画ツールを作成したので実装過程をまとめてみる。 WEBアプリケーションで描画処理といえばHTML5Canvasを利用することが多いと思われるが、今回はCanvasをベースにPaper.jsというフレームワークを利用して実装した。当初は純粋にCanvasだけで実装を進めようとしていたが、結構な壁に当たってしまった。そこで色々探した結果Paper.jsに辿り着き、非常に使い勝手が良さそうだったのでこれで実装を進めることにした。

完成イメージ

  • キャンバスをクリックしてパスを描画する
  • クリックした位置(多角形の頂点になる)にマーカーの長方形を描画する
  • パスを閉じて多角形を作る
  • 多角形の面積を計算する
  • 描画を全てクリアする
f:id:rozured:20201111011619g:plain

メインは多角形の描画に関する処理で面積計算はおまけ。

開発環境

  • Angular 10.1.2
  • Angular CLI 10.1.2
  • typescript 4.0.3
  • Node 14.11.0
  • paper 0.12.11

Paper.jsとは?

通常HTML5Canvasはラスター形式で描画を行うが、Paper.jsを利用するとCanvasをベースにしつつベクター形式で描画することができる。ベクター形式なので見た目が綺麗であるだけでなく、描画オブジェクトの作成や操作に関する便利なメソッドなどが多く用意されており、Canvasをそのまま使うよりも簡単に色々な機能を実装できる。

paperjs.org

ディレクトリ構成

src/app以下のディレクトリ構成は下記の通り。 今回の説明で登場するのはplot-area.component.htmlplot-area.component.tsvertex.tsの3つだけである。

app
├── app-routing.module.ts
├── app.component.html
├── app.component.scss
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
├── routed-modules
│   └── plotter
│       └── components
│           └── plot-area
│               ├── plot-area.component.html
│               ├── plot-area.component.scss
│               ├── plot-area.component.spec.ts
│               └── plot-area.component.ts
└── shared
    └── model
        └── vertex.ts

実装

Paper.jsの導入と初期設定

はじめに、npmコマンドでPaper.jsをインストールする。

$ npm install paper


ベースはCanvasなので、まずはhtmlファイルに<canvas></canvas>タグを記述する。 このcanvas要素を コンポーネントのクラスファイルから参照するため、参照変数として#canvasを付加している。canvas要素の幅と高さはwidthheightで指定する。明示的に指定しない場合はデフォルトで幅300px、高さ150pxに設定される。

<canvas #canvas width="600px" height="500px"></canvas>


マウスポインタの座標値を格納しておくクラスとしてVertexクラスを定義しておく。主に頂点の座標を一覧表示したり多角形の面積を計算したりする時に使う。

export class Vertex {
  x: number;
  y: number;
}


次にコンポーネントのクラスファイルを記述していく。 @ViewChild(‘canvas’, { static: true }) canvas: ElementRef<HTMLCanvasElement>の部分で、参照変数が#canvasであるcanvas要素のノードを取得しcanvasという名の変数に格納している。{ static: true }オプションを付けることでngOnInit()内で canvas要素にアクセスできるようになる。{ static: false }もしくは指定しない場合は、ngAfterViewInit()を利用しないとcanvas要素にアクセスできないので若干面倒くさい。ngOnInit()paper.setup(this.canvas.nativeElement)とすれば描画が可能となる。

initialItemSetting()

この後の描画に必要になってくる多角形のPathオブジェクトをnew Path()で予め生成しておく。さらに、キャンバスをクリックする度にマーカーとなるShapeオブジェクトを追加していくことになるので、PathShapeオブジェクト達を1つのグループにまとめておくほうが都合がいい。したがって、それらをまとめて格納するためのGroupオブジェクトをnew Group()で生成する。つまり、「Groupオブジェクト=多角形全体の情報をまとめたもの」であり、Groupオブジェクトは1個のPathオブジェクト(多角形の辺を全て繋げたもの)と頂点の個数分のShapeオブジェクトの集合体として考えると分かりやすい。まずは、GroupオブジェクトにaddChild()を使ってPtahオブジェクトを追加する。

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';

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

  // パスオブジェクト関係
  path: any;
  pathGroup: any;
  unsettledPath: any;
  // マウスポインターの座標関係
  currentX: number;
  currentY: number;
 // 面積計算関係
  vertexList: Vertex[] = [];
  polygonArea: number;

  constructor() { }

  ngOnInit(): void {
    paper.setup(this.canvas.nativeElement);
    this.initialItemSetting();
  }

  private initialItemSetting(): void {
    this.path = new Path();
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
  }
}

マウスの現在位置の座標を取得する

コンポーネントのクラスファイルに以下のメソッドを追加していく。

getCurrentPosision()

まず、様々な機能を実装していくための第一歩として、キャンバス領域を基準としたマウスポインタの現在位置の座標を取得してみる。 clientXclientYで取得できるのはクライアント領域(ウインドウ枠内の領域)を基準としたマウスポインタの座標なので、キャンバス領域基準の相対的なマウスポインタの座標は、クライアント領域基準の「マウスポインタの座標 - キャンバスの左上の座標」とすれば得られる。キャンバスの左上の座標はgetBoundingClientRect()メソッドで取得することができる。 取得したマウスポインタの現在位置のx, y座標をそれぞれcurrentXcurrentXに格納し、この後の様々な描画処理で使い回す。

f:id:rozured:20201113004702p:plain:w512:h384
  getCurrentPosision(event): void {
    const rect = event.target.getBoundingClientRect();
    this.currentX = event.clientX - rect.left;
    this.currentY = event.clientY - rect.top;
  }


html側では、キャンバス要素にmousemoveイベントをバインディングし、キャンバス上をマウスが移動する度にgetCurrentPosision()を呼び出す。

<canvas #canvas id="canvas" #dot width="600px" height="500px" (mousemove)="getCurrentPosision($event)"></canvas>

クリックしてパス、頂点マーカーを描画する

コンポーネントのクラスファイルに以下のメソッドを追加していく。

onClickCanvas()

クリック位置のマウスポインタ座標のオブジェクト(Vertexクラス)をvertexListに追加していく。その後、描画用のメソッドとしてplotMarker()drawLine()を呼ぶ。

plotMarker()

クリック位置にマーカーとなる図形を描画し、その箇所が一目で分かるようにする。今回はマーカーとして正方形を描画していく。new Shape.Rectangle()で引数に中心座標center、正方形のサイズsize、線色strokeColorを指定すれば正方形のShapeオブジェクトを生成・描画することができる。さらに、先に作っておいたpathGroupGroupオブジェクト)に正方形オブジェクトを追加していきたいので、addChild()を使って順次追加していく。

drawLine()

初めにパスの線色や太さといったスタイルを設定している。this.path.add(new Point(this.currentX, this.currentY))の部分で、Pathオブジェクトに節点(セグメントと言う)を追加していくことで、クリックした点が次々と線分で結ばれていくような動作になる。add()メソッドの引数として渡す情報はPointオブジェクトである必要があるため、new Point(this.currentX, this.currentY)で生成した現在位置座標のPointオブジェクトを渡している。

  onClickCanvas(): void {
    // パスの頂点座標の配列にクリック位置のx, y座標を追加する
    this.vertexList.push({
      x: this.currentX,
      y: this.currentY,
    });
    this.plotMarker();
    this.drawLine();
  }

  private plotMarker(): void {
    // 正方形のマーカー(パスの頂点を明示する印)を生成する
    const marker = new Shape.Rectangle({
      center: new Point(this.currentX, this.currentY),
      size: 8,
      strokeColor: 'rgb(255, 0, 0)',
    });
    this.pathGroup.addChild(marker);
  }

  private drawLine(): void {
    this.path.strokeColor = 'rgb(255, 0, 0)';
    this.path.strokeWidth = 2;
    this.path.add(new Point(this.currentX, this.currentY));
  }


html側では、キャンバス要素にclickイベントをバインディングし、キャンバス上をクリックする度にonClickCanvas()を呼び出す。

<canvas #canvas id="canvas" #dot width="600px" height="500px" (mousemove)="getCurrentPosision($event)"
    (click)="onClickCanvas()">
</canvas>

パスを閉じる

プロット点数が3点未満だと多角形になり得ないので、パスのセグメントが3点未満の場合はパスを閉じる処理を行えないようにする。 対象のPathオブジェクトに対してclosePath()を使うことで、現在の末端のセグメントと始点のセグメントを結ぶ、つまりパスを閉じることができる。パスを閉じた後は、多角形の領域が分かりやすいように内部をthis.path.fillColor = 'rgb(255, 0, 0, 0.2)'で塗りつぶす(透過度を指定して薄い赤にしている)。最後にthis.calculatePolygonArea()メソッド(後述)を呼び出して多角形の面積計算を実行する。

  closePath(): void {
    // プロット数が3点未満の場合はパスを閉じられないようにする
    if (this.path.segments.length < 3) { return; }
    this.path.closePath();
    this.path.fillColor = 'rgb(255, 0, 0, 0.2)';
    this.calculatePolygonArea();
  }


html側では、パスを閉じるボタンにclickイベントをバインディングしてclosePath()を呼び出す。

<div class="control-area">
  <button (click)="closePath()">パスを閉じる</button>
  <button>描画をクリア</button>
  <ng-container *ngIf="currentX && currentY">
    X:{{currentX}} Y:{{currentY}}
  </ng-container>
</div>

面積を計算する

「パスを閉じる」ボタンのクリックをトリガーとして多角形の面積を計算する。ここは特に描画とは関係ないため頂点座標リストvertexListの要素を使った繰返し計算を行うだけである。繰返し計算にはreduce()を使っている。多角形の各頂点の座標から面積を計算する式は下記を使う。ベクトル計算とか外積とかいろいろやるとこうなるらしい。

$$ S = \frac{1}{2} \left| \displaystyle \sum_{i=1}^{n}(x_i y_{i+1} - x_{i+1} y_i) \right| $$ $x_i$と$y_i$は多角形の頂点$P_i$の座標値を表す。
$i=n$(多角形パスの末尾の頂点)の場合は$n+1=1$(始点)となるので注意。

計算した結果を面積の変数polygonAreaに格納し画面に表示する。

  calculatePolygonArea(): void {
    // 多角形の面積を計算する
    const lastIndex = this.vertexList.length - 1;
    const sum = this.vertexList.reduce((prev, curValue, curIndex, array) => {
      const nextIndex = curIndex === lastIndex ? 0 :curIndex + 1;
      return prev + (curValue.x * array[nextIndex].y - array[nextIndex].x * curValue.y)
    }, 0);
    this.polygonArea = Math.abs(sum) / 2;
  }

描画をクリアする

paper.project.activeLayer.removeChildren()でキャンバス上のアイテムの全ての子(今回はパスのセグメント)要素を削除する。Pathオブジェクト自体が削除されるものではない。面積計算の結果と頂点座標リストもクリアしておく。 最後に、this.initialItemSetting()メソッドを呼び直すことでPathオブジェクトやShapeオブジェクトを初期化し、まっさらな状態にする。

  clearAll(): void {
    paper.project.activeLayer.removeChildren();
    this.polygonArea = null;
    this.vertexList = [];
    this.initialItemSetting();
  }


html側では、描画をクリアボタンにclickイベントをバインディングしてclearAll()メソッドを呼び出す。

<div class="control-area">
  <button (click)="closePath()">パスを閉じる</button>
  <button (click)="clearAll()">描画をクリア</button>
  <ng-container *ngIf="currentX && currentY">
    X:{{currentX}} Y:{{currentY}}
  </ng-container>
</div>

おわりに

ここまでの実装で、キャンバス上をクリックしながら任意の多角形を描画する、各頂点の座標値の一覧と多角形の面積を表示する、という基本的なものが出来上がる。 次の記事では、パスの交差を制限する処理と、頂点マーカーのドラッグ移動処理を追加していく。


参考