HAKUTAI Tech Notes

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

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

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

完成イメージ

多角形の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;
    };

これで、マウスポインタと多角形のセグメント、辺が重なっているかどうかを判定できるようになったので、openMenu()内で頂点編集メニューを開くか否かの処理を記述することができる。

  openMenu(event: MouseEvent): boolean {
    // カーソルが多角形のセグメント上にもストローク上にもない場合は頂点編集メニューを開かない
    if (!this.isMouseOnSegment && !this.isMouseOnStroke) { return true; } // 追加
    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();
  }


後は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を指定する。

後は、頂点座標リスト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公式ページのサンプル集をざっと見ても分かる通り、さらに多くの機能が用意されている。 今回その中のほんの一部を利用しただけなので今後も色々試していきたい。


参考