HAKUTAI Tech Notes

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

AngularとPaper.jsで簡易なデジタイザを作る4

概要

これまで実装してきたのは、Viewの操作や座標変換といった下準備のものである。いよいよ本題の座標点のプロット機能を実装していくことになる。とは言っても、キャンバス上でパスを描画して頂点の座標値を表示する方法などは既往の記事で解説済みなので、ここではその他の処理を中心に述べていく。

完成イメージ

  • 座標点パスをプロットする
  • プロットした座標値をテーブルに表示する
  • Escapeキー押下でプロットを中断、再開する
  • 座標点パスを全選択しDeleteキー押下でパスを削除する
f:id:rozured:20210626172248g:plain

実装

パスの描画

まずは、コンポーネントのクラスファイルの記述から。今後使用する変数を一気に追加しておこう。

export class DigitizerComponent implements OnInit {

    // 省略

  editStartPlotX: number; // 追加
  editStartPlotY: number; // 追加

    // 省略

  // 各種フラグ
  isScaleEndRange = false;
  isEditAxis = false;
  isMouseOnStroke = false; // 追加
  isItemDragging = false; // 追加
  isPlotting = false; // 追加
  isViewDragging = false; // 追加
  isMouseOnSegment = false; // 追加
  // オンマウス状態のパスの子オブジェクト
  activeLocation: any; // 追加
  activeSegment: any; // 追加
  // レイヤー・グループ
  backgroundLayer: Layer;
  plottingLayer: Layer;
  settingLayer: Layer;
  // プロット関係
  path: any; // 追加
  pathGroup: any; // 追加
  unsettledPath: any; // 追加
  vertexList: Vertex[] = []; // 追加
  // コンテキストメニュー関係
  contextMenuPosition = { x: '0px', y: '0px' }; // 追加
  isEditMenuOpened = false; // 追加

    // 省略
}

プロットした座標点のクラスとしてVertexクラスを定義しておき、modelsディレクトリを用意してその下に置いておく。 (座標点用にVertexのようなクラスを定義しておくかどうかは趣味の問題なので特に必須というわけではない。)

app
├── app-routing.module.ts
├── app.component.html
├── app.component.scss
├── app.component.ts
├── app.module.ts
├── components
│   └── digitizer
│       ├── digitizer.component.html
│       ├── digitizer.component.scss
│       └── digitizer.component.ts
└── models
│   └── vertex.ts
export class Vertex {
  x: number;
  y: number;
}

パスの描画は過去の記事で解説済みなので詳細はそちらを参照してほしい。 今回も確定パス(プロットされて既に位置が確定しているパス)と未確定パス(確定パスの最先端のセグメントとマウスポインタの位置を結んだパス)の2つのパスを描画していく。なお、今回はパス同士が交差していても特に問題はないので交差判定の処理は入れていない。注意すべき点は、キャンバス上に各種オブジェクトを描画する時、描画の起点はView座標系の座標値で設定するということだ。

なお、後々キーボードイベントと紐付けてプロットを中断、再開する機能を追加していくので、それぞれ「プロット中断状態」「プロット可能状態」という言い方で区別していく。これ以降で頻繁に登場するisPlottingというフラグは、プロット可能状態でtrue、プロット中断状態でfalseとする想定である。isPlottingの切り替え処理は「プロットの中断、再開」のところで実装するとして、とりあえずパスを描画する時の諸々の処理ではisPlottingによる実行の制御を入れておく。

initialPathItemsSetting()

確定パスpathと未確定パスunsettledPathを生成し、pathpathGroupに追加する。plottingLayerpathGroupunsettledPathを追加してプロット処理に関わるオブジェクトをまとめる。 プロットに必要なパスなどの初期設定を行った後、isPlottingをtrueにすることで満を辞してプロットを始めることができる。

  private initialPathItemsSetting(): void {
    this.plottingLayer.removeChildren();
    this.unsettledPath = new Path();
    this.path = new Path();
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
    this.plottingLayer.addChildren([
      this.pathGroup,
      this.unsettledPath,
    ]);

    // プロット準備完了
    this.isPlotting = true;
  }

initialPathItemsSetting()setImageToCanvas()内で呼び出せば、背景画像を読み込む時に座標点パスなどをリセットできる。同時に座標点リストvertexListもクリアしておく。

  private setImageToCanvas(): void {
    // 背景画像をクリア
    this.backgroundLayer.removeChildren();
    // viewの表示設定をクリア
    this.resetViewConfig();
    // 座標軸設定パスをリセット
    this.setRangePath();
    // プロット用パスをリセット
    this.initialPathItemsSetting(); // 追加
    // 座標点リストをリセット
    this.vertexList = []; // 追加

        // 省略

  }
onClickCanvas()

キャンバスをクリックして座標点を打つためのメソッドでありこの後clickイベントと紐づけることになる。しかし、clickイベントは静的クリック(単純にキャンバス上の一箇所をクリックする)以外にドラッグをした時も発火してしまう。これではドラッグで背景画像の表示位置を調整しただけで座標点がプロットされてしまい都合が悪い。

そこで、isViewDraggingというフラグによって静的クリックなのかドラッグなのかを区別する。背景画像をドラッグしている時にisViewDraggingがtrueになるとして、ドラッグ中やプロット中断状態(isPlottingがfalse)ならプロット処理は行わないようにする。

クリックした位置の座標値(プロット座標系)を座標点リストvertexListに追加し、プロット点を示すマーカーとパスの線を描画するメソッドを呼び出す。

  onClickCanvas(): void {
    if (this.isViewDragging || !this.file || !this.isPlotting) {
      this.isViewDragging = false;
      return;
    }
    // 座標点リストにクリック位置のx, y座標を追加する
    this.vertexList.push({
      x: this.plotX,
      y: this.plotY,
    });
    this.plotMarker();
    this.drawLine();
  }

背景画像をドラッグしているかどうかを判別するために、setEventsToView()onMouseDragハンドラでisViewDraggingをtrueにする処理を追加する。

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

クリック位置のマーカーとして円を描画している。描画する円の中心座標はView座標系のcurrentXcurrentYを使うので注意。

  private plotMarker(insertIndex?: number): void {
    // 正方形のマーカー(パスの頂点を明示する印)を生成する
    const marker = new Shape.Circle({
      center: new Point(this.currentX, this.currentY),
      size: 8,
      strokeColor: '#ff0000',
    });
    if (insertIndex) {
      // 頂点追加処理の場合、パスグループの既存の子要素配列の間に挿入する
      this.pathGroup.insertChild(insertIndex + 1, marker);
    } else {
      this.pathGroup.addChild(marker);
    }
  }
drawLine()

クリック位置の座標点で生成したSegmentオブジェクトをpathに追加して線を描画している。セグメントの座標はView座標系のcurrentXcurrentYを使うので注意。

  private drawLine(): void {
    this.path.strokeColor = '#ff0000';
    this.path.strokeWidth = 1;
    this.path.add(new Point(this.currentX, this.currentY));
    this.unsettledPath.removeSegments();
  }
drawUnsettledLine()

確定パスの中で最先端(末尾)のセグメントとマウスカーソルを結んだ「未確定パス」を描画する。

  private drawUnsettledLine(): void {
    if (this.path.segments.length === 0 || !this.path.lastSegment) { 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));
  }

setCurrentPosision()で、isPlottingがtrueの時だけdrawUnsettledLine()が呼び出されるようにする。 これで、プロット可能状態の時にマウスカーソルの動きに未確定パスが追従するようになる。

  setCurrentPosision(event): void {
    if (!this.file) { return; }
    const rect = event.target.getBoundingClientRect();
    this.currentX = view.viewToProject(event.clientX - rect.left).x;
    this.currentY = view.viewToProject(event.clientY - rect.top).y;
    const plotXY = this.convertViewToPlot(this.currentX, this.currentY);
    this.plotX = plotXY.x;
    this.plotY = plotXY.y;
    // ↓追加
    if (this.isPlotting) {
      this.drawUnsettledLine();
    }
  }


次に、プロット処理の呼び出し元であるイベントの紐付けをhtmlに記述する。 canvas要素のclickイベントをトリガーにしてonClickCanvas()メソッドを呼び出す。

<canvas class="m-0" #canvas (mousewheel)="scalingView($event)" (mousemove)="setCurrentPosision($event)"
  (click)="onClickCanvas()" [class.onStroke]="isEditAxis && isMouseOnStroke" [class.isPlotting]="isPlotting" [class.onSegment]="isMouseOnSegment" (contextmenu)="openMenu($event)">
</canvas>

ついでに、プロットを開始した後は座標軸を編集できないようにしておく。プロット中、つまりvertexListが空でない場合は「座標軸を設定する」ボタンが非活性になるように、disabledの条件にvertexList.length !== 0を追加する。 パスを全削除したりすれば再度座標軸は設定できる。

<button mat-raised-button (click)="setAxisRange()" class="ml-2" [class.editingAxis]="isEditAxis"
  [disabled]="!file || vertexList.length !== 0">
  {{ isEditAxis ? '座標軸の設定終了' : '座標軸を設定する' }}
</button>

プロットの中断、再開

座標点のプロットを中断して各座標点を編集(移動、追加、削除)できるようにする。もしくは、プロットを中断している状態からプロットを再開できるようにする。プロット可能状態と中断状態を切り替える処理は任意のキーと紐づける。紐づけるキーは何でもいいが、今回はEscapeキーの押下によって切り替えることにする。

キーボードイベントの設定では@HostListenerデコレータを利用する。 Escapeキーの押下を受けて実行されるイベントハンドラhandleKeyboardEvent()として、 @HostListener('document:keydown', ['$event'])を付けて定義してやると、keydownイベント発生時にイベントハンドラが実行される。

handleKeyboardEvent()内で具体的に行いたいことは、isPlottingフラグの切り替えと、未確定パスの表示・非表示の切り替えである。ただし、現状ではどのキーを押下した時でも実行されてしまうので、特にEscapeキーを押下した時のみ処理を行うようにevent.key === 'escape'の判定をつけておく。また、Escapeキーの押下を認識して処理を行うのは、「背景画像が読み込まれている」かつ「座標軸を設定していない」かつ「少なくとも1点以上プロットされている」という条件が満たされていたほうがいいのでif文で制御しておく。

@Component({
  selector: 'app-digitizer',
  templateUrl: './digitizer.component.html',
  styleUrls: ['./digitizer.component.scss']
})
export class DigitizerComponent implements OnInit {

// 省略

  @HostListener('document:keydown', ['$event'])
  handleKeyboardEvent(event: KeyboardEvent): void {
    // プロット可能状態の切り替え
    if (event.key === 'Escape') {
      if (this.file && !this.isEditAxis && this.path.segments.length > 0) {
        this.isPlotting = !this.isPlotting;
        // 未確定パスの表示・非表示を切り替える
        if (this.isPlotting) {
          this.drawUnsettledLine();
        } else {
          this.unsettledPath.removeSegments();
        }
      }
    }
  }

}

座標軸を設定中はプロットできないようにしたほうがいいので、isEditAxisの逆の値でisPlottingを更新する処理をsetAxisRange()に追加する。

  setAxisRange(): void {
    this.isEditAxis = !this.isEditAxis;
    this.isPlotting = !this.isEditAxis; // 追加
    this.settingLayer.locked = !this.isEditAxis;
    this.settingLayer.visible = this.isEditAxis;
    this.plottingLayer.locked = this.isEditAxis;
  }

座標点の移動、追加と削除

プロットを中断している間は、任意の座標点間(パス上のセグメント間)に新たな座標点を追加したり座標点を削除できるようにする。加えて、既にプロットされた座標点の位置をドラッグ移動で調整できるようにする。 座標点のドラッグ移動の詳細座標点の追加・削除は過去の記事で解説済みなので詳細はそちらを参照してほしい。

openMenu()

右クリックした時に座標点の追加・削除メニューを表示する。プロット可能状態、もしくはパスのセグメント・線分上にカーソルがない(isMouseOnSegmentがfalse かつisMouseOnStrokeがfalse)場合は表示しない。 右クリックした位置の座標値をプロット座標系に変換してeditStartPlotXeditStartPlotYに格納している。 メニュー表示の起点contextMenuPositionにはView座標系のclientXclientYを設定すればよいが、この後で座標点を追加する時に必要なのはeditStartPlotXeditStartPlotYになるので区別している点に注意。

  openMenu(event: MouseEvent): boolean {
    if (this.isPlotting || (!this.isMouseOnSegment && !this.isMouseOnStroke)) { return true; }
    this.isEditMenuOpened = true;
    // デフォルトのコンテキストメニューを開かないようにする
    event.preventDefault();
    const editStartPlotXY = this.convertViewToPlot(this.currentX, this.currentY);
    this.editStartPlotX = editStartPlotXY.x;
    this.editStartPlotY = editStartPlotXY.y;
    this.contextMenuPosition.x = event.clientX + 'px';
    this.contextMenuPosition.y = event.clientY + 'px';
    this.contextMenu.openMenu();
  }

htmlの方にはコンテキストメニューのテンプレートを追加しておく。

<!-- プロット点追加・削除実行用コンテキストメニュー -->
<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 (click)="addSegment()" [class.disabled]="!isMouseOnStroke">座標点の追加</div>
    <div mat-menu-item (click)="removeSegment()" [class.disabled]="!isMouseOnSegment">座標点の削除</div>
  </ng-template>
</mat-menu>
setMouseEventToPlottedPath()

プロットして描画されるパスに対して各種マウスイベントを設定していく。プロット中断状態のみパスの編集操作を可能とするので、各イベントハンドラの最初の部分にisPlottingによる制御が入っている。

onMouseMoveで座標点パスの線分(ストローク)やセグメントとマウスカーソルの当たり判定を、onMouseDragで座標点パスのセグメントのドラッグ移動を、onMouseUponMouseLeaveで各種フラグなどのクリアをそれぞれ行なっている。

  private setMouseEventToPlottedPath(): void {
    this.path.onMouseMove = (event) => {
      if (this.isPlotting || this.isEditMenuOpened) { return; }
      // セグメントとストロークの当たり判定のみを有効にする
      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;
    };

    this.path.onMouseDrag = (event) => {
      if (this.isPlotting || !this.activeSegment) { return; }
      const index = this.activeSegment.index;
      this.isItemDragging = 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;
      // 座標点リストを更新する
      this.vertexList[index].x = this.plotX;
      this.vertexList[index].y = this.plotY;
    };

    this.path.onMouseUp = () => {
      if (this.isPlotting || !this.activeSegment) { return; }
      // プロット点用コンテキストメニューが開かれていない場合だけactiveItemとオンマウス、ドラッグのフラグをクリアする
      if (!this.isEditMenuOpened) {
        this.activeSegment = null;
        this.isMouseOnSegment = false;
      }
      this.isItemDragging = false;
    };

    this.path.onMouseLeave = () => {
      if (this.isPlotting || this.isItemDragging || this.isEditMenuOpened) { return; }
      // セグメントからマウスが離れた場合はactiveItemとオンマウスのフラグをクリアする
      this.activeSegment = null;
      this.activeLocation = null;
      this.isMouseOnSegment = false;
      this.isMouseOnStroke = false;
      this.isItemDragging = false;
    };
  }

座標点をドラッグしている時に背景画像も一緒に動いてしまってはダメなので、setEventsToView()onMouseDragの最初のif文にisMouseOnSegmentによる判定を追加する。

  private setEventsToView(): void {
    const tool = new Tool();
    tool.onMouseDrag = (event) => {
      // 画像読み込み前、またはカーソルが座標点パス上にある場合は背景画像の平行移動は無効
      if (!this.file || !!this.activeLocation || this.isMouseOnSegment) { return; } // isMouseOnSegmentの判定を追加
      this.isViewDragging = true;
      // delta = 最後にクリックされた位置の座標 - 現在地の座標
      const delta = event.downPoint.subtract(event.point);
      view.scrollBy(delta);
      this.updateRangePath();
    };
  }

initialPathItemsSetting()の中で、 座標点パスを生成した後にsetMouseEventToPlottedPath()を呼び出す。

  private initialPathItemsSetting(): void {
    this.plottingLayer.removeChildren();
    this.unsettledPath = new Path();
    this.path = new Path();
    this.setMouseEventToPlottedPath(); // 追加
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
    this.plottingLayer.addChildren([
      this.pathGroup,
      this.unsettledPath,
    ]);
  }
afterMenuClosed()

座標点の編集メニューを閉じた時の処理。

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

座標点を追加する処理。座標点パスに挿入する座標はView座標系のcurrentXcurrentY、座標点リストに挿入する座標はプロット座標系のeditStartPlotXeditStartPlotYと使い分けているので注意。

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

座標点を削除する処理。

  removeSegment(): void {
    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;
  }

パスの選択と削除

プロットした座標点パスをまとめて全部削除する機能を作る。プロット中断状態で座標点パスをクリックするとパス全体が選択状態になり、そのままBackspaceキーを押下するとパス全体を削除できるようにする。

setMouseEventToPlottedPath()に、パスをクリックした時の処理を追加する。 ここでは、Pathオブジェクトのselectedプロパティを利用する。selectedがtrueであるパスは青い線と正方形が重なって表示されるので、selectedの真偽を切り替えてやればパスが選択されているかどうか一目で分かるようになる。

f:id:rozured:20210611182401p:plain

  private setMouseEventToPlottedPath(): void {

    // 省略

    this.path.onClick = () => {
      if (this.isPlotting) { return; }
      // パスの選択状態を切り替える
      this.path.selected = !this.path.selected;
    };
  }

次に、Backspaceキーを押下した時の処理をhandleKeyboardEvent()に追記していく。プロットの中断・再開でEscapeキー押下時の処理を記述したのと同様に、Backspaceキーを押下した時だけ実行するのでevent.key === 'backspace'の判定をつけておく。削除処理なので念のためconfirmで確認メッセージを表示して、OKなら座標点パスのセグメントや各種フラグなどをリセットする。(pathそのものを削除するのではなくpathに含まれるセグメントを削除する)

  @HostListener('document:keydown', ['$event'])
  handleKeyboardEvent(event: KeyboardEvent): void {

        // 省略

    // プロットパスの削除処理
    if (event.key === 'Backspace') {
      if (this.path.selected && !this.isEditAxis) {
        if (confirm('パスを削除してよろしいですか?')) {
          // パスのセグメントを削除
          this.path.removeSegments();
          // プロットマーカーはindex=1以降に格納されているので全て削除
          this.pathGroup.children.splice(1);
          // 座標点リストをリセット
          this.vertexList = [];
          this.path.selected = false;
          this.isPlotting = true;
          this.activeLocation = null;
        }
      }
    }
  }

この処理によってパスの全削除を行うことで、座標軸の再設定や座標点のプロットを最初からやり直すことができる。

おわりに

メインの座標点プロット機能ができあがり、「画像読込み→座標軸設定→プロット」という一連の操作ができるようになったのでデジタイザとしては一応完成となる。 次の記事では仕上げとして、プロットした座標点の値をCSV出力してみる。 デジタイザに関してPaper.js絡みの話は一旦ここで終わりとなる。 あともう一息。


参考

HostListenerについて