Angular と SVG で Zoom / Drag ができる Grid を作る

はじめに

先日、技術書典マーケットにて「入門GUI ーWebブラウザで作る本格インタラクションー」なる本を書いました。
React と SVG でバウンデイングボックスを作る章があり、SVG を使ったとこもない私も楽しむことのできる内容でした。

今回はせっかく学んだことがあったので Angular と SVG を使って Zoom / Drag and Drop ができる Grid を作ります。
要は node editor なんかでよく使われている拡大拡小できる方眼紙 を作ります。

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

stackblitz.com

Angular と SVG で Grid を描画する

今回は Angular Flex-Layout との組み合わせも試してみます。
Angular Flex-Layout 入門 という記事にインストールから使い方までを書いていますので参考にしてください。

注意点としては Flex-Layout で囲むと svg は display: flex; 内の要素として振舞うことになりますが、その際には width と height を指定する必要があります。
そもそも viewBox のみを指定しておけばレスポンシブに振舞うので特別 Flex-Layout を利用する必要がなければそうすべきかもしれません。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">
    <svg width="500" height="500" viewBox="250 250 500 500" #svgGrid>
      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>
  </div>
</div>

この時点で以下のような SVG のグリッドが描画されます。

rect として 一番大きな外枠の四角を 1001 の大きさで描画し、中身をそれぞれ 100, 10 の大きさの四角で埋めています。
線の太さを少しずつ変えることで方眼紙っぽさ(?)を演出できていますね。

viewBox として viewBox="250 250 500 500" を指定し、1001 の大きさの四角内の中央 500 x 500 を 1:1 で描画しています。 ここら辺は stackblitz のような使い捨て環境でいろいろいじって体感してください。

flex-layout grid
flex-layout grid

Angular と SVG で Grid を Drag できるようにする

次に Grid を Drag できるようにしてみます。
Drag は具体的には viewBox の表示位置 viewBox="x, y, 500, 500" の x と y を変えることで実現します。

html で Pointer Event を取得

まずは html 側で pointer Event を取得できるようにします。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">

    <svg
      (pointerdown)="downHandleGrid($event)"
      (pointermove)="moveHandleGrid($event)"
      width="500"
      height="500"
      viewBox="250 250 500 500"
      #svgGrid>

      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>

  </div>
</div>

typescript で Pointer Event を実装

typescript 側で各 Event に対する振る舞いを実装していきます。

PointerDown Event

PointerDown Event は downHandleGrid() としてドラッグが始まったことを isDraggingGrid に格納し、ドラッグが始まった座標を gridDownClientX / gridDownClientY に格納しています。

PointerMove Event

PointerMove Event は moveHandleGrid() としてドラッグが始まった座標からの差分を計算し viewBox の表示位置を変更しています。

PointerUp Event

PointerUp Event は document要素 に対する HostListener として upHandle() を実装しています。
svg 要素外で PointerUp してもドラッグが終了したことを検知するためです。

import { Component, HostListener, ViewChild, ElementRef } from '@angular/core';
import { Point } from './interfaces/point';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  @ViewChild('svgGrid', { read: ElementRef }) svgGrid: ElementRef<SVGSVGElement>;

  isDraggingGrid = false;
  gridDownClientX: number;
  gridDownClientY: number;

  @HostListener( 'document:pointerup', [ '$event' ] )
  public upHandle(event: PointerEvent) {
    this.isDraggingGrid = false;
  }

  downHandleGrid(pointerEvent: PointerEvent) {
    this.isDraggingGrid = true;
    pointerEvent.preventDefault();
    this.gridDownClientX = pointerEvent.clientX;
    this.gridDownClientY = pointerEvent.clientY;
  }

  moveHandleGrid(pointerEvent: PointerEvent){
    if (this.isDraggingGrid ) {
      pointerEvent.preventDefault();
      const delta: Point = {
        x: pointerEvent.clientX - this.gridDownClientX,
        y: pointerEvent.clientY - this.gridDownClientY
      };
      this.gridDownClientX = pointerEvent.clientX;
      this.gridDownClientY = pointerEvent.clientY;
      this.updateViewBoxMin(delta.x, delta.y);
    }
  }

  updateViewBoxMin(dx: number, dy: number): void {
    const viewBoxList = this.svgGrid.nativeElement.getAttribute('viewBox').split(' ');
    viewBoxList[0] = '' + this.cutoffDragRange(parseInt(viewBoxList[0], 10) - dx);
    viewBoxList[1] = '' + this.cutoffDragRange(parseInt(viewBoxList[1], 10) - dy);
    const viewBox = viewBoxList.join(' ');
    this.svgGrid.nativeElement.setAttribute('viewBox', viewBox);
  }

  cutoffDragRange(draggingPoint: number): number{
    if (draggingPoint < 0) {
      return 0;
    } else if (draggingPoint > 501) {
      return 501;
    }
    return draggingPoint;
  }
}

これで SVG の Drag が実現できました。
要素数が重ならず少量であればとてもシンプルですね。

Angular と SVG で Grid を Zoom ができるようにする

次に Grid を Zoom ができるようにしてみます。
Zoom は具体的には viewBox の表示位置 viewBox="x, y, width, height" の width と height を変えることで実現します。

html で Wheel Event を取得

まずは html 側で Wheel Event を取得できるようにします。

<div fxLayout="column">
  <div fxLayout="row" fxFlex="500px" fxLayoutAlign="center center">

    <svg
      (pointerdown)="downHandleGrid($event)"
      (pointermove)="moveHandleGrid($event)"
      (wheel)="wheelHandleGrid($event)"
      width="500"
      height="500"
      viewBox="250 250 500 500"
      #svgGrid>

      <defs>
        <pattern id="smallGrid" width="10" height="10" patternUnits="userSpaceOnUse">
          <path d="M 10 0 L 0 0 0 10" fill="none" stroke="gray" stroke-width="0.5"/>
        </pattern>
        <pattern id="grid" width="100" height="100" patternUnits="userSpaceOnUse">
          <rect width="100" height="100" fill="url(#smallGrid)"/>
          <path d="M 100 0 L 0 0 0 100" fill="none" stroke="gray" stroke-width="1"/>
        </pattern>
      </defs>
      <rect width="1001" height="1001" fill="url(#grid)" />
    </svg>

  </div>
</div>

typescript で Pointer Event を実装

typescript 側で Pointer Event に対する振る舞いを実装していきます。

Wheel Event

Wheel Event は wheelHandleGrid() として WheelEvent の発生した座標と Zoom なのか Pan なのか (wheelEvent.deltaY が正負どちらか) を取得し this.scaleFactor = 1.02 という倍率で Zoom / Pan を行っています。

  wheelHandleGrid(wheelEvent: WheelEvent){
    wheelEvent.preventDefault();
    const position = this.getEventPosition(wheelEvent);
    const scale = Math.pow(this.scaleFactor, wheelEvent.deltaY < 0 ? 1 : -1);
    this.zoomAtPoint(position, this.svgGrid.nativeElement, scale);
  }

  getEventPosition(wheel: WheelEvent): Point {
    const point: Point = {x: 0, y: 0};
    if (wheel.offsetX) {
      point.x = wheel.offsetX;
      point.y = wheel.offsetY;
    } else {
      const { left, top } = (wheel.srcElement as Element ).getBoundingClientRect();
      point.x = wheel.clientX - left;
      point.y = wheel.clientY - top;
    }
    return point;
  }

  zoomAtPoint(point: Point, svg: SVGSVGElement, scale: number): void {
    const sx = point.x / svg.clientWidth;
    const sy = point.y / svg.clientHeight;
    const [minX, minY, width, height] = svg.getAttribute('viewBox')
      .split(' ')
      .map(s => parseFloat(s));
    const x = minX + width * sx;
    const y = minY + height * sy;
    const scaledMinX = this.cutoffScaledMin(x + scale * (minX - x));
    const scaledMinY = this.cutoffScaledMin(y + scale * (minY - y));
    const scaledWidth = this.cutoffScaledLength(width * scale);
    const scaledHeight = this.cutoffScaledLength(height * scale);
    const scaledViewBox = [scaledMinX, scaledMinY, scaledWidth, scaledHeight]
      .map(s => s.toFixed(2))
      .join(' ');
    svg.setAttribute('viewBox', scaledViewBox);
  }

  cutoffScaledMin(scaledMin: number): number{
    return scaledMin >= 0 ? scaledMin : 0;
  }
  cutoffScaledLength(length: number): number{
    return length <= 750 ? length : 750;
  }

これで SVG の Zoom が実現できました。
Zoom を行うと重なる要素を動かす際の倍率を気にする必要がありますがそれは次回にでも。

まとめ

Angular と SVG で Zoom / Drag ができる Grid を作る方法をまとめました。
最初は rxjs の fromevent を多用して Event の処理を行っていましたが、動的に要素が追加されると少し難しさがあったので従来の方法で実装してみました。

次回はこの Grid に Drag 可能な図形を動的に追加してみます。

参考

wemo.tech

norando.net