Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する

はじめに

先日、技術書典マーケットにて「入門GUI ーWebブラウザで作る本格インタラクションー」なる本を書い Angular と SVG で何かしたいなぁと思う最近です。

前回の Angular と SVG で Zoom / Drag ができる Grid を作るの続きとして Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する方法 をまとめます。

先に作った stackblitz を貼っておきます。

stackblitz.com

Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する

前回作成した Grid 上に Button を押すことで Drag 可能な SVG を生成していきます。

動的に SVG NodeLayer を生成する

まずは生成する SVG の Node の Interface を規程します。

export interface NodeLayer {
  id: string;
  width: number;
  height: number;
  positionX: number;
  positionY: number;
  rotate: number;
  color: string;
  rx: number;
  ry: number;
  isSelected: boolean;
  shadowFilter: string;
}

次に html 側に ボタンを置き Click すると上記 Interface の NodeLayer が生成されるようにします。

<div>
  <button mat-raised-button (click)="addNodeLayer()" color="primary">Add</button>
</div>

生成の仕方はいくつかあると思いますが、今回は単純に NodeLayer を配列にして ngFor で回して描画する方法とします。
defs としてかっこいい(?)影を nodeLayer.shadowFilter で切り替えてます。

<svg>
  
  ~ 略 ~

  <defs>
    <filter id="shadow">
      <feDropShadow dx="3" dy="3" stdDeviation="0" flood-color="rgba(0, 0, 0, .5)" flood-opacity="0.5"/>
    </filter>
    <filter id="liftedShadow">
      <feDropShadow dx="4" dy="4" stdDeviation="0" flood-color="rgba(0, 0, 0, .5)" flood-opacity="0.7"/>
    </filter>
  </defs>
  <g *ngFor="let nodeLayer of nodeLayers">
    <rect
      (pointerdown)="downHandleNodeLayer($event, nodeLayer)"
      (click)="clickHandleNodeLayer($event, nodeLayer)"
      attr.id="{{nodeLayer.id}}"
      attr.x="{{ nodeLayer.positionX - nodeLayer.width / 2 }}"
      attr.y="{{ nodeLayer.positionY - nodeLayer.height / 2 }}"
      attr.width="{{ nodeLayer.width }}"
      attr.height="{{ nodeLayer.height }}"
      attr.rx="{{ nodeLayer.rx }}"
      attr.ry="{{ nodeLayer.ry }}"
      stroke="black"
      stroke-width="0.1"
      attr.fill="{{ nodeLayer.color }}"
      attr.filter="{{ nodeLayer.shadowFilter }}">
    </rect>
  </g>

  ~ 略 ~

</svg>

addNodeLayer() は typescript 側で以下のように実装します。
ある程度中央っぽい位置に対して round() 関数を適用することで、前回作成した Grid の間隔 10 刻みで配置できるようにしています。

  ~ 略 ~

  addNodeLayer() {
    const randomMin = 250;
    const randomMax = 500;
    const randomColor = ['white'];
    const w = 100;
    const h = 100;
    const pX = this.round(Math.floor( Math.random() * (randomMax + 1 - randomMin) ) + randomMin);
    const pY = this.round(Math.floor( Math.random() * (randomMax + 1 - randomMin) ) + randomMin);

    const newNodeLayer: NodeLayer = {
      id: uuidv4(),
      width: w,
      height: h,
      positionX: pX,
      positionY: pY,
      rotate: 0,
      color: randomColor[ Math.floor( Math.random() * randomColor.length ) ],
      rx: 10,
      ry: 10,
      isSelected: false,
      shadowFilter: 'url(#shadow)'
    }
    this.nodeLayers.push(newNodeLayer);
  }

  round(v) {
    return Math.round(v / 10) * 10;
  }

  ~ 略 ~

Add ボタンを押すと、以下のように SVG 図形が生成されたはずです。

grid add svg node
grid add svg node

生成した SVG NodeLayer を 削除可能にする

生成した NodeLayer を削除できるようにしてみます。
実際には Click で選択された NodeLayer に対して Delete キーを検知して nodeLayers リストから削除する機能を作っていきます。

生成した SVG NodeLayer を選択できるようにする

選択された NodeLayer は selectedNodeLayers という配列に格納していきます。
下記 clickHandleNodeLayer() で Event と Click された nodeLayer を取得しています。

  <rect
    ~ 略 ~
    (click)="clickHandleNodeLayer($event, nodeLayer)"
    ~ 略 ~
  </rect>

ここでは shiftKey を押しながらクリックされているかを検知し shiftKey が押されていなければ新たにクリックされた nodeLayer のみを selectedNodeLayers の配列に格納し、 shiftKey が押された状態であれば複数の nodeLayer を selectedNodeLayers に格納しています。

また、選択されたことをわかりやすくするため #shadow#liftedShadow で影の濃さを変化させています。

stopPropagation() を忘れると Click Event が Grid まで透過してしまうのでうまく機能しませんので気をつけてください。

  ~ 略 ~

  clickHandleNodeLayer(pointerEvent: PointerEvent, nodeLayer: NodeLayer) {
    pointerEvent.preventDefault();
    pointerEvent.stopPropagation();

    if (!pointerEvent.shiftKey) {
      if (this.selectedNodeLayers.length > 0){
        this.selectedNodeLayers.forEach((selectedSVGLayer: NodeLayer) => {
          selectedSVGLayer.isSelected = false;
          selectedSVGLayer.shadowFilter = 'url(#shadow)';
        });
        this.selectedNodeLayers = [];
      }
    }

    nodeLayer.isSelected = true;
    nodeLayer.shadowFilter = 'url(#liftedShadow)';
    this.selectedNodeLayers.push(nodeLayer);
  }

  ~ 略 ~

また、 Grid をクリックした時に全ての選択された nodeLayer を解除する機能も入れておきます。
grid に clickHandleGrid($event) を登録し Click Event を受け取れるようにしておいて、以下のように selectedNodeLayers 配列をリセットします。

  ~ 略 ~
  clickHandleGrid(pointerEvent: PointerEvent) {
    pointerEvent.preventDefault();
    if (this.selectedNodeLayers.length > 0){
      this.selectedNodeLayers.forEach((selectedNodeLayer: NodeLayer) => {
        selectedNodeLayer.isSelected = false;
        selectedNodeLayer.shadowFilter = 'url(#shadow)';
      });
      this.selectedNodeLayers = [];
    }
  }
  ~ 略 ~

Sfiht キーを押しながら nodeLayer をクリックすることで以下のように選択できたかと思います。

grid add svg node
grid add svg node

選択した SVG NodeLayer を削除できるようにする

選択した NodeLayer の削除を行います。
delete キーの Keyup Evetn を受け取って削除を nodeLayer の isSelected のものを削除します。

  ~ 略 ~
  @HostListener('document:keyup', ['$event'])
  public handleKeyboardEvent(keyboardEvent: KeyboardEvent) {
    keyboardEvent.preventDefault();
    if (keyboardEvent.keyCode === 8 || keyboardEvent.keyCode === 46) {
      if (this.selectedNodeLayers.length > 0){
        this.nodeLayers = this.nodeLayers.filter(nodeLayer => !nodeLayer.isSelected);
      }
    }
  }
  ~ 略 ~

生成した SVG NodeLayer を Drag 可能にする

では次に NodeLayer を Drag できるようにします。
NodeLayer で Pointerdown Event を受け取れるようにします。

<rect
  ~ 略 ~
  (pointerdown)="downHandleNodeLayer($event, nodeLayer)"
  ~ 略 ~
</rect>

これで Drag する対象の NodeLayer が判別できるようになりました。

Pointermove は document に対しての Event として受け取ります。
これは Pointer を早く動かした際に Pointer が NodeLayer から外れてしまった際にも NodeLayer の Drag を継続させたいためです。

@HostListener( 'document:pointermove', [ '$event' ] )
public moveHandle(pointerEvent: PointerEvent){
  pointerEvent.preventDefault();
  pointerEvent.stopPropagation();
  if (!this.isDraggingGrid && this.isDraggingNodeLayer) {
    const viewBoxList = this.svgGrid.nativeElement.getAttribute('viewBox').split(' ');
    const aspX = (parseInt(viewBoxList[2], 10) / 500);
    const aspY = (parseInt(viewBoxList[3], 10) / 500);
    // move NodeLayer
    if (pointerEvent.offsetX) {
      this.draggingNodeLayer.positionX = this.round((pointerEvent.offsetX * aspX) + parseInt(viewBoxList[0], 10)) ;
      this.draggingNodeLayer.positionY = this.round((pointerEvent.offsetY * aspY) + parseInt(viewBoxList[1], 10)) ;
    } else {
      const { left, top } = (pointerEvent.srcElement as Element).getBoundingClientRect();
      this.draggingNodeLayer.positionX = pointerEvent.clientX - left + parseInt(viewBoxList[0], 10);
      this.draggingNodeLayer.positionY = pointerEvent.clientY - top + parseInt(viewBoxList[1], 10);
    }
  }
}

以上で生成した SVG NodeLayer を Drag できるようになりました。

まとめ

Angular と SVG で Grid 上に Drag 可能な図形を動的に生成する方法をまとめました。

次回は NodeLayer を path で結線する機能を実装しようと思います。