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

ここまで実装してきたパス描画ツールに最後にもう少し機能を追加していく。 パスが既に閉じられた多角形に対して、任意の辺に頂点を追加したり任意の頂点を削除できるようにする。 開発環境やディレクトリ構成は前回前々回と変わらないので省略する。これまで実装してきた機能も含めた全体のソースはこちら

完成イメージ

f:id:rozured:20201111014457g:plain

多角形の1辺のパス上で右クリックして頂点編集メニューを開き、頂点の追加を選んでセグメントの追加を行えるようにする。また、既に存在しているセグメント上で右クリックして頂点編集メニューを開き、頂点の削除を選んでセグメントの削除を行えるようにする。 以下、右クリックで開くデフォルトのコンテキストメニューに対して、今回実装したいコンテキストメニューのことを「頂点編集メニュー」と呼ぶことにする。頂点編集メニューは、Angular MaterialのmatMenuを利用して実装していく。

実装

頂点編集メニューを表示する

まず始めに、contextmenuイベントと頂点編集メニュー表示用メソッドのopenMenu($event)を紐付ける。 頂点編集メニューのテンプレートは、plot-area.component.htmlの一番下に追加していく。メニューの実態は<mat-menu>タグ内部に記述する。さらに、メニューのトリガー用の要素として<div>を定義し、matMenuTriggerFor="contextMenu"mat-men要素と紐付ける。[style.left][style.top]にメニュー表示の起点となる座標contextMenuPositionを指定する。

<canvas #canvas width="600px" height="500px" (mousemove)="getCurrentPosision($event)"
  (click)="onClickCanvas()" [class.onSegment]="isMouseOnSegment" (contextmenu)="openMenu($event)">
</canvas>

<!-- 中略 -->

<!-- 頂点追加・削除実行用コンテキストメニュー -->
<div style="visibility: hidden; position: fixed;" [matMenuTriggerFor]="contextMenu" 
  [style.left]="contextMenuPosition.x"[style.top]="contextMenuPosition.y">
</div>
<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent class="context-menu">
    <div mat-menu-item>頂点の追加</div>
    <div mat-menu-item>頂点の削除</div>
  </ng-template>
</mat-menu>


続いてコンポーネントのクラスファイルに追加していく。
まず @ViewChild(MatMenuTrigger) contextMenu: MatMenuTriggerでhtmlファイルに記述したメニュー要素を取得し、contextMenuという変数でこれ以降利用していく。

openMenu()

始めに、頂点編集メニューが開かれているかどうかを判定するフラグisEditMenuOpenedをtrueにする。 次に、右クリックした時点のマウスポインタの座標値をこの後別の場所で使うので、editStartXeditStartYにそれぞれ格納しておく。 頂点編集メニューを表示する起点はマウスポインタの位置にしたいので、contextMenuPosition.xcontextMenuPosition.yにはeventオブジェクトから取得できるマウスポインタの座標値を格納する。なお、matMenuはクライアント領域(ウインドウ枠内の領域)が基準となるので、キャンバス領域を基準にしたcurrentXcurrentYを起点に指定してしまうとメニューの表示位置がずれてしまうので注意。最後のthis.contextMenu.openMenu()で頂点編集メニューを表示する。

  @ViewChild(MatMenuTrigger)
  contextMenu: MatMenuTrigger;

  // マウスポインターの座標関係
  currentX: number;
  currentY: number;
  editStartX: number; // 追加
  editStartY: number; // 追加

  // 各種フラグ
  isCross = false;
  isMouseOnSegment = false;
  isMouseOnStroke = false; // 追加
  isMouseDragging = false;
  // オンマウス状態のパスの子オブジェクト
  activeSegment: any;
  activeLocation: any; // 追加
  // コンテキストメニュー関係
  contextMenuPosition = { x: '0px', y: '0px' }; // 追加
  isEditMenuOpened = false; // 追加


// 中略 //

  openMenu(event: MouseEvent): boolean {
    this.isEditMenuOpened = true;
    // デフォルトのコンテキストメニューを開かないようにする
    event.preventDefault();
    // 右クリックした時点のマウスポインターの座標を保持する
    this.editStartX = this.currentX;
    this.editStartY = this.currentY;
    this.contextMenuPosition.x = event.clientX + 'px';
    this.contextMenuPosition.y = event.clientY + 'px';
    this.contextMenu.openMenu();
  }


これでとりあえずキャンバス上のどこでも右クリックすれば、「頂点の追加」「頂点の削除」の項目があるだけの頂点編集メニューが表示される。ついでに頂点編集メニューのデザインも適当に設定しておくと良い。

::ng-deep .mat-menu-panel {
  width: 100px;
  height: 60px;
  background-color: #ffffff;
  .mat-menu-item {
    font-size: 12px;
    text-align: center;
    line-height: 25px;
    height: 25px;
    &:hover {
      // .mat-menu-itemの要素はデフォルトでマウスカーソルがpointerになる
      color: #ffffff;
      background-color: #5e5e5e;
    }
  }
}

頂点編集メニューを制御する

右クリックで任意の場所に頂点編集メニューを表示できるようになったので、次はその表示タイミングや選択項目の活性・非活性状態の制御を行う。


右クリックした時のマウスポインタの位置と、それに対応する頂点編集メニューの状態は下記の通りにする。

マウスポインタの位置 頂点編集メニューの状態
多角形の辺(ストローク)上 頂点の追加を活性、頂点の削除を非活性にする
多角形のセグメント上 頂点の追加を非活性、頂点の削除を活性にする
多角形の辺・セグメント以外 表示しない
(デフォルトのコンテキストメニューを表示)

まずは頂点編集メニューを閉じた時に呼ぶafterMenuClosed()を定義し、各種フラグや当たり判定で取得するオブジェクトをクリアする。activeLocationisMouseOnStrokeについては後述する。

  afterMenuClosed(): void {
    this.isEditMenuOpened = false;
    this.activeSegment = null;
    this.activeLocation = null;
    this.isMouseOnSegment = false;
    this.isMouseOnStroke = false;
  }


次に、前回の記事で登場したsetMouseEventToPath()onMouseMoveonMouseLeaveイベントに色々追加していく。

onMouseUp
頂点編集メニューが開かれている時は多角形に対するイベントを一切無効にしたいので、isEditMenuOpenedフラグを使って制御する。これまでは、マウスポインタと多角形のセグメントとの当たり判定を考慮してactiveSegmentだけを取得していたが、今回はそれに加えてマウスポインタと重なる多角形の辺情報activeLocationを取得する。hitOptionsstrokeをtrueに変更すればマウスポインタと辺がヒットした時にその位置に関する情報をCurveLocationオブジェクトとして取得できる。マウスポインタが辺上にあるかどうかを判定するフラグisMouseOnStrokeは、activeLocationの有無を利用して更新しておく。

    this.path.onMouseMove = (event) => {
      // 頂点編集メニューが表示されている場合はイベントを実行しない
      if (this.isEditMenuOpened) { return; } // 追加
      if (this.polygonArea) {
        // セグメントとストロークの当たり判定のみを有効にする
        const hitOptions = {
          fill: false,
          stroke: true, // 変更
          segments: true,
          tolerance: 1,
        };
        const hitResult = paper.project.hitTest(event.point, hitOptions);
        this.activeSegment = hitResult && hitResult.segment;
        this.isMouseOnSegment = !!this.activeSegment;
        this.activeLocation = hitResult && hitResult.location; // 追加
        this.isMouseOnStroke = !!this.activeLocation; // 追加
      }
    };


onMouseLeave
頂点編集メニューを操作する時にマウスポインタが多角形から離れることもあるが、そこでactiveSegmentがnullになってしまうのは不都合である。頂点編集メニューが開かれている時はonMouseLeaveの処理を行わないようにしたいので、先頭にisEditMenuOpenedフラグによる制御を入れる。onMouseLeaveイベントの処理の修正はこれだけ。

    this.path.onMouseLeave = () => {
      // 頂点編集メニューが表示されている場合はイベントを実行しない
      if (this.isEditMenuOpened) { return; } // 追加
      if (this.activeSegment) {
        // セグメントをドラッグしている途中の場合は処理を行わない
        if (this.isMouseDragging) { return; }
        // セグメントからマウスが離れた場合はactiveItemとオンマウスのフラグをクリアする
        this.activeSegment = null;
        this.isMouseOnSegment = false;
      }
      this.isMouseOnStroke = false;
    };


これで、マウスポインタと多角形のセグメント、辺が重なっているがどうかを判定できるようになったので、後はhtmlファイルを編集すれば良い。メニューのトリガー用のdiv要素には、MatMenuTriggerに用意されているmenuClosedイベントとafterMenuClosed()を紐付ける。また、頂点編集メニューテンプレート内のmat-menu-item要素に対して、非活性スタイルを適用するクラスdisabledを脱着できるようにすればメニュー項目の活性・非活性の制御を実現できる。

<div style="visibility: hidden; position: fixed;" [matMenuTriggerFor]="contextMenu" (menuClosed)="afterMenuClosed()"
  [style.left]="contextMenuPosition.x"[style.top]="contextMenuPosition.y">
</div>
<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent class="context-menu">
    <div mat-menu-item [class.disabled]="!isMouseOnStroke">頂点の追加</div>
    <div mat-menu-item [class.disabled]="!isMouseOnSegment">頂点の削除</div>
  </ng-template>
</mat-menu>
  .disabled {
    pointer-events: none;
    color: #dfdfdf;
  }

頂点の追加、削除処理を実装する

頂点編集メニューを制御できるようになったので、メニューの各項目に対応するメソッドを定義して中身を記述していく。

addSegment()

頂点(セグメント)を追加するためのメソッド。 既存のPathオブジェクトのセグメントリストに新しいセグメントを挿入するPath.insert()を利用する。引数にはセグメントを挿入するインデックスinsertIndexとその座標オブジェクトを渡してやれば良い。 マウスポインタが、ある線分とヒットした時に取得できるCurveLocationオブジェクトのindexは、その線分の始点側のセグメントのindexに一致する。これを利用すれば、例えばn番目とn+1番目のセグメントで結ばれる線分の中に新たにセグメントを挿入したい時は、insertIndexとしてn(= 線分の始点 = CurveLocation.index) + 1を指定してやれば良い。挿入するセグメントの座標はeditStartXeditStartYを指定する。
f:id:rozured:20201111015803p:plain

後は、頂点座標リストvertexListにもsplice()で新しいセグメントの座標を追加し、plotMarker()を呼び出して新しいセグメントの位置にマーカー(Shapeオブジェクト)を描画すれば完了である。最後に頂点編集メニューを閉じておく。

  addSegment(): void {
    const insertIndex = this.activeLocation.index + 1;
    this.path.insert(insertIndex, new Point(this.editStartX, this.editStartY));
    this.vertexList.splice(insertIndex, 0, {x: this.editStartX, y: this.editStartY});
    this.plotMarker(this.editStartX, this.editStartY, insertIndex);
    this.contextMenu.closeMenu();
  }
removeSegment()

頂点を削除するためのメソッド。 まず始めに、頂点が3個未満の多角形は存在し得ないので、現在の頂点数が3個の場合は削除できないようにする。 次は頂点を削除する処理を実装していく。既存のPathオブジェクトのセグメントリストからセグメントを1つ削除する場合は、Path.removeSegment()の引数に削除するセグメントのインデックスremoveIndexを渡すことになる。removeIndexは、マウスカーソルがヒットしているセグメントのインデックスをそのまま指定すれば良い。 頂点座標リストvertexListからもremoveIndex番目の要素を削除してやる。さらに、セグメントを削除した位置にあったマーカーを消すため、pathGroupの子要素から当該のShapeオブジェクトを削除する。Groupオブジェクトの子要素を削除する時はremoveChildren()を利用するが、引数には削除する要素の開始インデックスと終了インデックス(端点は含まない)を渡す。例えばn番目の要素を削除するのであればremoveChildren(n, n+1)となる。ただし、1つ注意が必要なのは、pathGroupの子要素の先頭(index = 0)にはpathオブジェクトを格納しているため、Shapeオブジェクトは2番目(index = 1)以降から始まることになる。したがって、n番目の要素を削除するのであればremoveChildren(n+1, n+2)とする必要がある。 最後に頂点編集メニューを閉じ、マウスポインタがセグメントから離れるのでisMouseOnSegmentをfalseに更新する。

  removeSegment(): void {
    // 現在の頂点数が3個の場合は削除できないようにする
    if (this.path.segments.length === 3) {
      alert('多角形の描画には3個以上の頂点が必要です。');
      this.contextMenu.closeMenu();
      return;
    }
    const removeIndex = this.activeSegment.index;
    this.path.removeSegment(removeIndex);
    this.vertexList.splice(removeIndex, 1);
    this.pathGroup.removeChildren(removeIndex + 1, removeIndex + 2);
    this.contextMenu.closeMenu();
    this.isMouseOnSegment = false;
  }


後は、実装したメソッドと頂点編集メニューの項目をクリックイベントで紐付けてやれば終わりとなる。

<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent class="context-menu">
    <div mat-menu-item (click)="addSegment()" [class.disabled]="!isMouseOnStroke">頂点の追加</div>
    <div mat-menu-item (click)="removeSegment()" [class.disabled]="!isMouseOnSegment">頂点の削除</div>
  </ng-template>
</mat-menu>

おわりに

前々回、前回の記事から続いていたが、以上でパス描画ツールは完成となる。 Paper.js公式ページのサンプル集をざっと見ても分かる通り、さらに多くの機能が用意されている。 今回その中のほんの一部を利用しただけなので今後も色々試していきたい。


参考

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

前回の記事ではパス描画ツールの基本的な処理を実装した。今回はさらに機能を拡張し、パスの交差制御と頂点マーカーのドラッグ移動を実装していく。

完成イメージ

  • パスが交差していたら警告メッセージを表示して操作を取り消す
  • 頂点をドラッグ移動して位置を更新する
  • 頂点を移動したら座標リストや面積計算の結果を更新する
f:id:rozured:20201111011454g:plain

実装

パスの交差を制限する

多角形の面積を計算する時、パス同士が交差してしまうことは望ましくない(交差点を基準に複数の多角形に分けてそれぞれの面積を計算した後に合算する方法もあるが面倒くさい)。なので、パスが交差する位置に点を打てないようにしてしまえばよい。 具体的には、既にキャンバスをクリックして位置が確定されているパス(以下、確定パス)と、確定パスの最先端のセグメントとマウスポインタの位置を結んだパス(以下、未確定パス)の2つのパスの交差判定を行い、両者が交差している状態では点を打てないようにする。確定パスの描画は前の記事で述べたので、ここでは未確定パスの描画について述べる。

f:id:rozured:20201027014224p:plain

まず、未確定パスのオブジェクトとしてunsettledPathを定義し、initialItemSetting()の中でPathオブジェクトを生成してunsettledPathに格納する。

export class PlotAreaComponent implements OnInit {
  @ViewChild('canvas', { static: true })
  canvas: ElementRef<HTMLCanvasElement>;

  // パスオブジェクト関係
  path: any;
  pathGroup: any;
  unsettledPath: any; // 追加

 // 中略 //

  // 各種フラグ
  isCross = false;

 // 中略 //

  private initialItemSetting(): void {
    this.path = new Path();
    this.unsettledPath = new Path(); // 追加
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
  }
}
drawUnsettledLine()

未確定パスunsettledPathを描画するためのメソッド。未確定パスは、確定パスの最先端のセグメントを始点、マウスポインタの位置を終点としたパスであり、マウスポインタがキャンバス上を移動すれば未確定パスもそれに伴って移動する。確定パスの最先端のセグメントはthis.path.lastSegment.pointで取得し、マウスカーソルの位置はいつも通りthis.currentXthis.currentYを使う。this.unsettledPath.add()で始点と終点のPointブジェクトを追加することで未確定パスを描画している。なお、マウスポインタが移動する時に移動前の未確定パスが残らないように予めthis.unsettledPath.removeSegments()で未確定パスの始点と終点のセグメントを取り除いて描画を破棄している。

  private drawUnsettledLine(): void {
    if (!this.path || !this.unsettledPath || this.polygonArea) { return; }
    this.unsettledPath.removeSegments();
    // 未確定パスの設定
    this.unsettledPath.strokeColor = 'rgb(0, 0, 0, 0.1)';
    this.unsettledPath.strokeWidth = 1;
    // 確定パスの最先端にある頂点座標を取得する
    const lastSegment = this.path.lastSegment.point;
    // 未確定パスの始点
    this.unsettledPath.add(new Point(lastSegment.x, lastSegment.y));
    // 未確定パスの終点
    this.unsettledPath.add(new Point(this.currentX, this.currentY));

    // 確定パスと未確定パスの交差を判定する
    this.checkCrossing();
  }
checkCrossing()

パスの各セグメントの座標から数学的に線分同士の交差判定を行う方法もあるが、今回はPaper.jsで用意されているgetIntersections()を用いた判定を行う。パスA.getIntersections(パスB)のようにすると、2つのパスA, Bの全ての交差をCurveLocationオブジェクトの配列として取得することができる。今回は確定パスと未確定パスの交差点の配列を取得することになる。ただし、1つ注意すべきは、確定パスと未確定パスの接続点(確定パスの最先端のセグメント)も交差点の一種と見なされるため、既に交差点の配列にはその接続点が1つ含まれている。したがって、確定パスと未確定パスが本当に交差しているかどうかは、交差点の配列の要素数が1より大きいかどうかを確認すればよい。その場合は、交差判定用フラグisCrossをtrueにする。

  private checkCrossing(): void {
    const interSection = this.path.getIntersections(this.unsettledPath);
    this.isCross = interSection.length > 1;
  }
getCurrentPosision()

drawUnsettledLine()を呼び出す処理を追加する。

  getCurrentPosision(event): void {
    const rect = event.target.getBoundingClientRect();
    this.currentX = event.clientX - rect.left;
    this.currentY = event.clientY - rect.top;
    this.drawUnsettledLine(); // 追加
  }
onClickCanvas()

isCrossがtrueの時はキャンバスをクリックしても描画できないようにしたいので、メソッドの先頭にisCrossによる条件分岐を追加する。また、交差している場合にその旨が分かるようにポップアップでメッセージを出すようにしている。

  onClickCanvas(): void {
    if (this.isCross) {
      alert('パスが交差する位置に点を打つことは出来ません。');
      // 交差フラグをクリアする
      this.isCross = false;
      return;
    }
      // 中略 //
  }
drawLine()

クリックして確定パスを描画したら元の未確定パスは消す必要があるので、this.unsettledPath.removeSegments()で未確定パスのセグメントを削除し描画をクリアする。

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

頂点のドラッグ移動を可能にする

パスを閉じて多角形の描画が完了した後、多角形の頂点マーカーの位置をドラッグ移動によって修正できるようにする。頂点のマーカーを移動させた後は多角形の面積を再計算する。 描画された各オブジェクトそれぞれに対してマウスイベントを設定していく必要がある。描画オブジェクトに対してマウスイベントを設定する処理はコンポーネントのクラスファイルに記述していくことになる。

まずは必要な変数を定義しておく。

export class PlotAreaComponent implements OnInit {
  @ViewChild('canvas', { static: true })
  canvas: ElementRef<HTMLCanvasElement>;

    // 省略 //

  // 各種フラグ
  isCross = false;
  isMouseOnSegment = false;
  isMouseDragging = false;
  // オンマウス状態のパスの子オブジェクト
  activeSegment: any;

    // 省略 //
}

次に、多角形のパスに対して各種のマウスイベントを設定していく。Pathオブジェクトのpathを生成した後、setMouseEventToPath()メソッド(後述)を呼び出し、pathに対してマウスイベントを設定する。

  private initialItemSetting(): void {
    this.path = new Path();
    this.setMouseEventToPath(); // 追加
    this.unsettledPath = new Path();
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
  }

  private setMouseEventToPath(): void {
    // 内容は後述 //
  }
setMouseEventToPath()

Pathオブジェクトには各種のイベントハンドラが用意されており、イベントと処理を紐付けたい時は、

// Pathオブジェクト上をマウスポインタが移動した時に何らかの処理を実行させる例
Path.onMouseMove = () => { 処理 }

のように記述する。 今回はonMouseMoveonMouseDragonMouseUponMouseLeaveの4イベントを利用する。
onMouseMove
path上でマウスポインタが移動した時に実行する処理を定義する。 まず、マウスポインタpathの当たり判定を行う。 paper.projecthitTest()メソッドは、第1引数の座標と描画オブジェクトとの当たり判定を行い結果(paper.HitResult型のオブジェクト)を返す。当たりがなければnullが返る。当たり判定のオプションはhitOptionsで指定し、当たり判定を有効にする部分や判定精度を設定できる。描画オブジェクトの塗り潰し部分、線分、セグメントに対して当たり判定を行いたい場合は、それぞれfillstrokesegmentをtrueにすればよい。toleranceは当たり判定の精度でデフォルトは0である。toleranceが小さすぎるとマウス操作がシビアになるし、toleranceが大きすぎると複数の要素(線分とセグメントなど)が隣接している場合にうまく当たり判定を取得できないので、状況に応じて調整するのがよい。

今回はセグメントに対する当たり判定だけあればいいので、hitOptionssegmentのみtrueにしている。マウスポインタとセグメントが当たった時はhitResult.segmentで対象のSegmentオブジェクトを取得することができるので、そのSegmentオブジェクトをactiveSegmentに格納する。また、マウスポインタがセグメント上か否かを判定するフラグisMouseOnSegmentをtrueにする。

    this.path.onMouseMove = (event) => {
      if (this.polygonArea) {
        // セグメントとストロークの当たり判定のみを有効にする
        const hitOptions = {
          fill: false,
          stroke: true,
          segments: true,
          tolerance: 1,
        };
        const hitResult = paper.project.hitTest(event.point, hitOptions);
        this.activeSegment = hitResult && hitResult.segment;
        this.isMouseOnSegment = !!this.activeSegment;
      }
    };

onMouseDrag
path上の要素がドラッグされている時に実行する処理を定義する。 activeSegmentが存在(マウスポインタがセグメント上にある)する場合のみ実行する。

まず、セグメントをドラッグしている最中か否かを判定するフラグisMouseDraggingをtrueにする。 event.point.xevent.point.yでドラッグ中のマウスポインタの座標を参照できるので、activeSegmentの座標をマウスポインタの座標で更新する。同時に、頂点マーカーもドラッグに追随させたいので、ドラッグ中のactiveSegmentに対応するマーカーの座標をマウスポインタの座標で更新する。activeSegmentindexがN番目である時、それに対応するマーカーのShapeオブジェクトはpathGroupのN+1番目の子要素になる。これは、pathGroupの先頭(index = 0)にはpathが格納されていて、2番目(index = 1)以降からマーカーのShapeオブジェクトが格納されているためである。

さらに、頂点のドラッグ移動によってパスが交差してしまうことを防ぐため、this.path.getIntersections(this.path)でパスの交点の配列を取得して交差判定を行う。前項では確定パスと未確定パスという異なる2つのパスの交差を判定していたが、このように自分自身との交差判定を行うこともできる。ただし、確定パスと未確定パスの場合は接続点が1つあるので交差点の配列の長さ > 1を交差ありの条件としていたが、今回は確定パス1本だけの交差判定なので交差点の配列の長さ > 0が交差ありの条件となるので注意。

    this.path.onMouseDrag = (event) => {
      if (this.activeSegment) {
        const index = this.activeSegment.index;
        this.isMouseDragging = true;
        // パスのセグメントの座標を更新する
        this.activeSegment.point.x = event.point.x;
        this.activeSegment.point.y = event.point.y;
        // パス頂点のマーカーの座標を更新する
        this.pathGroup.children[index + 1].position.x = event.point.x;
        this.pathGroup.children[index + 1].position.y = event.point.y;
        // パス同士の交差を判定する
        const interSection = this.path.getIntersections(this.path);
        this.isCross = interSection.length > 0;
      }
    };

onMouseUp
path上の要素のドラッグが終了した時に実行する処理を定義する。 activeSegmentが存在する場合のみ実行する。

頂点をドラッグ移動してパスが交差してしまう場合は、マウスボタンを離した時にセグメントとマーカーを移動前の位置に強制的に戻す。移動前の位置の座標はvertexListに格納されているので、activeSegmentに対応するvertexListの要素を参照して座標を更新する。パスが交差していなければ問題ないので、activeSegmentの座標でvertexListの要素の座標を更新する。 ドラッグが終了したらisMouseDraggingをfalseに戻し、多角形の面積を再計算する。

    this.path.onMouseUp = () => {
      if (this.activeSegment) {
        const index = this.activeSegment.index;
        if (this.isCross) {
          // パスのセグメントの座標をドラッグ移動前に戻す
          this.activeSegment.point.x = this.vertexList[index].x;
          this.activeSegment.point.y = this.vertexList[index].y;
          // パス頂点のマーカーの座標をドラッグ移動前に戻す
          this.pathGroup.children[index + 1].position.x = this.vertexList[index].x;
          this.pathGroup.children[index + 1].position.y = this.vertexList[index].y;
          // セグメントからカーソルが離れるのでオンマウスのフラグをクリアする
          this.isMouseOnSegment = false;
          return;
        }
        this.vertexList[index].x = this.activeSegment.point.x;
        this.vertexList[index].y = this.activeSegment.point.y;
        this.isMouseDragging  = false;
        // 面積を再計算する
        this.calculatePolygonArea();
      }
    };

onMouseLeave
pathオブジェクトからマウスポインタが離れた時に実行する処理を定義する。 activeSegmentが存在する場合のみ実行する。

pathオブジェクトからマウスポインタが離れたらactiveSegmentをクリアしてisMouseOnSegmentをfalseに戻す。しかし、これだけだとセグメントをドラッグ移動している場合も(元々のセグメントの位置からずれるので)pathオブジェクトからマウスポインタが離れたと判定されてしまうため、セグメントのドラッグ中(isMouseDraggingがtrueの時)は処理を行わないようにする必要がある。

    this.path.onMouseLeave = () => {
      if (this.activeSegment) {
        // セグメントをドラッグしている途中の場合は処理を行わない
        if (this.isMouseDragging) { return; }
        // セグメントからマウスが離れた場合はactiveItemとオンマウスのフラグをクリアする
        this.activeSegment = null;
        this.isMouseOnSegment = false;
      }
    };


以上4つの処理をPathオブジェクトのイベントハンドラにバインドしてやることで、頂点を掴んで多角形の形を自由に変える機能を実現できる。

セグメント上でマウスカーソルの表示を切り替える

頂点をドラッグ移動させる機能は一応完成したが、最後にちょっとしたマウスカーソルの切替え処理を加えておく。マウスポインタが頂点とヒットした際にマウスカーソルをpointerに切り替えてドラッグ可能な場所であることを分かりやすくする。isMouseOnSegmentフラグを利用し、[class.onSegment]="isMouseOnSegment"マウスポインタがセグメント上にある時はcanvs要素にonSegmentクラスが付加されるようにし、onSegmentの要素に対してcssでデザイン調整を行う。

      <canvas #canvas id="canvas" #dot width="600px" height="500px" (mousemove)="getCurrentPosision($event)"
        (click)="onClickCanvas()" [class.onSegment]="isMouseOnSegment">
      </canvas>
  canvas.onSegment {
    &:hover {
      cursor: move;
    }
  }

おわりに

以上のイベントを設定することで、頂点マーカーをドラッグ移動して面積を再計算する動作が実現される。 ここまでで、多角形の描画と面積計算、頂点の移動といった一通りの機能を実装できた。 次の記事ではさらに発展させて、多角形の辺上の任意の位置に頂点を追加する機能と、頂点を削除する機能を追加してみる。


参考

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>

おわりに

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


参考