AWS の Diagram Maker を Angular に組み込んでみる

先日、AWS が Diagram Maker という画期的な UI ライブラリを公開しました。

ダイアグラムエディタ機能を簡単にアプリケーションに組み込むことができるらしいです。

でも残念ながらリリースされたばかりで、チュートリアルや解説記事がまだまだ少ない状況なんだよね。

そんなわけで、ちょっと触ってみて、できたところまでチュートリアルにしようと思いました。

それがこの記事です。

はじめに

先日、フロントエンド界隈でGigazine さんの記事にわかにバズった。 あの AWS が、ダイアグラムエディタを実装するライブラリ「Diagram Maker」を公開したという。

gigazine.net

ライブラリの紹介については当該記事に譲るとして、さっそく中身を触ってみることにした。 それもよりによって、React や Vue ではなく Angular と合わせて使ってみようと考えた。

完成(予定)図

今日はながちょうになることが予想されるので、モチベーションを上げるため先にゴールを示しておこう。

f:id:tercel_s:20201011170516p:plain

静止画でお伝えできないのが残念だが、ドラッグ & ドロップでいろんなところがぐりぐり動かせる

こんなインタラクティブコンポーネントがアプリケーションに組み込めたら、よりユーザフレンドリーなシステムが実現できるかもしれない。

開発環境について

本記事の内容は Node.js と Angular CLI を導入した Windows PC (Surface Pro) で検証した。

Angular CLI: 10.0.8
Node: 12.13.0
OS: win32 x64

Angular: 10.0.14
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router
Ivy Workspace: Yes

公式ドキュメント

チュートリアル ~ はじめての Diagram Maker

プロジェクトのひな型を作る

Angular プロジェクトの作成

Angular CLI が導入された環境でコマンドプロンプトを開き、以下のコマンドを実行する。

$ng new diagram-maker-test --routing --style css

このコマンドによって、diagram-maker-test フォルダといくつかのボイラープレートが作成される。 Angular 使いにとって、ここまではおなじみの手順だろう。

Diagram Maker のインストール

Diagram Maker は、npm または yarn 経由でインストールできる。

npm を利用する場合は、プロジェクトのフォルダに cd で移動し、npm install コマンドを実行する。

$cd ./diagram-maker-test
$npm install diagram-maker
$npm install dagre

2つ目にインストールしている dagre というライブラリは、実は必須ではない(公式ドキュメントには、「Workflow」というレイアウトを利用しない場合、dagre ライブラリの追加インストールは不要である旨が明記されている)。

Please note that the workflow layout uses the library Dagre which is not included within the DiagramMaker package and not marked as a peer dependency. This is because not all consumers of this library use the workflow based layout API. Using this without importing Dagre will cause client side errors in your application. For using this layout, please installe dagre into your application like so:npm install dagre

ただし、 dagre というライブラリも併せてインストールしないと、コンパイルのたびに毎回警告が出て気持ち悪いのだ。

Diagram Maker スタイルの設定

Diagram Maker には CSS が付属している。アプリケーション開始時にこの CSS を読み込むよう、angular.json に設定を追加する必要がある。

このファイルから、配列 styles を探し、先頭に「"node_modules/diagram-maker/dist/diagramMaker.css",」を追加しよう(styles は2箇所ある)。

angular.json

    "styles": [
+       "node_modules/diagram-maker/dist/diagramMaker.css",
        "src/styles.css"
    ],

lang 属性の修正

Angular CLI で自動生成したプロジェクトの場合、HTML のデフォルトの lang 属性が英語 (en) になっている。

index.html を開き、<html> タグの lang 属性を日本語 (ja) に修正しよう。

index.html

  <!doctype html>
- <html lang="en">
+ <html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>DiagramMakerTest</title>
    <base href="/">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="icon" type="image/x-icon" href="favicon.ico">
  </head>
  <body>
    <app-root></app-root>
  </body>
  </html>

ダイアグラムエディタのしたを作る

コンテナの作成

本節では、ダイアグラムエディタを配置するためのコンテナとして、画面の縦横いっぱいに広がる <div> タグを作る。

Angular アプリケーション全体に適用される styles.css に、以下のスタイルを追加しよう。

styles.css

html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;
}

続いて、app.component.css を開き、container クラスを定義する。

app.component.css

.container {
  width: 100%;
  height: 100%;
}

さらに、app.component.html を開く。 すでにコーディングされている内容は全て削除し、以下に置き換える。

app.component.html

<div class="container" #diagramEditorContainer>
</div>

このコンテナには、あとで Component クラスからアクセスする必要があるので、テンプレート参照変数 #diagramEditorContainer を書いておくのがポイントである。

ここまでの手順によって、縦横いっぱいに広がる <div> 要素ができた。 このページを、開発者ツールを有効にした Chrome で開くと以下のように見える(矢印と枠線は筆者による加筆)。

f:id:tercel_s:20201011193048p:plain

薄い青色が <div> の占有領域である。 画面いっぱいに広がっていることが判る。

Diagram Maker の初期化

ここからは Angular アプリケーションに Diagram Maker を組み込んでいこう。 app.component.ts を開き、以下のようにコーディングする。 これが、Diagram Maker を利用するために必要な最小限のコードだ。

app.component.ts

import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { DiagramMaker, DiagramMakerConfig, DiagramMakerNode } from 'diagram-maker';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit, OnDestroy {
  diagramMaker: DiagramMaker;

  @ViewChild('diagramEditorContainer')
  diagramEditorContainer: ElementRef;

  config: DiagramMakerConfig<{}, {}> = {
    renderCallbacks: {
      node: (node: DiagramMakerNode<{}>, container: HTMLElement) => {},
      destroy: (container: HTMLElement) => {},
      panels: {},
    }
  };

  // 初期化処理
  ngAfterViewInit(): void {
    this.diagramMaker = new DiagramMaker(
      this.diagramEditorContainer.nativeElement,
      this.config
    );

    window.addEventListener('resize', () => {
      this.diagramMaker.updateContainer();
    });
  }

  // 終了処理
  ngOnDestroy(): void {
    if (this.diagramMaker) {
      this.diagramMaker.destroy();
    }
  }
}

コード解説

app.component.ts はコーディング量が多いので、ここで一旦立ち止まって実装内容を簡単に説明しておく。

まず、コンポーネントの初期処理 (ngAfterViewInit()) に注目しよう。 DiagramMaker オブジェクトを new していることがわかる。 これが世界のはじまりである。

    this.diagramMaker = new DiagramMaker(
      this.diagramEditorContainer.nativeElement,
      this.config
    );

肝心なのは、コンストラクタに何を引き渡すかである。

DiagramMaker のコンストラクタには、最低限、① ダイアグラムエディタを配置するコンテナの DOM オブジェクトと、② 構成設定 (DiagramMakerConfig) を引き渡す必要がある。

コンテナの DOM は、先ほどのテンプレート参照変数を利用して @ViewChild() でアクセスできる。

  @ViewChild('diagramEditorContainer')
  diagramEditorContainer: ElementRef;

一方、第2引数の構成設定 (DiagramMakerConfig) には様々な設定情報を持たせることができるらしい(が、まだ僕も全体像を把握できずにいる)。

ほとんどの設定項目は省略可能で、もし何も書かなければ、いい感じのデフォルト値が設定されることが判っている。 ただし、renderCallbacks というプロパティだけは省略不可なので、ひとまず以下のから実装にしている。

  config: DiagramMakerConfig<{}, {}> = {
    renderCallbacks: {
      node: (node: DiagramMakerNode<{}>, container: HTMLElement) => {},
      destroy: (container: HTMLElement) => {},
      panels: {},
    }
  };

なお、ここで一旦から実装にしたり省略したりした内容は、この後の手順で育てることになるのでお楽しみに。

同じくコンポーネントの初期処理 (ngAfterViewInit()) の中で、window.resize イベントを処理している。

    window.addEventListener('resize', () => {
      this.diagramMaker.updateContainer();
    });

これは、コンテナのサイズが変更された場合、明示的に updateContainer() API を呼ぶ必要がある(とドキュメントに書いてある)からである。

Update Container

This API should be called by the consumer whenever there is a change to the size of the container that diagram maker is rendering in(Referred to by the first argument of diagram maker constructor). When diagram maker is rendered full screen, this should also be called when the window resizes.

コンテナのサイズ変更の検知には ResizeObserver を用いたアプローチもあるが、ここでは話を簡単にするため古典的な実装を採った。

続いて、コンポーネントの終了処理(ngOnDestroy()) に注目しよう。 DiagramMaker を明示的に destroy() している。

    if (this.diagramMaker) {
      this.diagramMaker.destroy();
    }

これも、公式ドキュメントによると自分で呼ばなくてはならないようだ。

Destroy

This API should be called by the consumer when the container is about to be destroyed. This usually happens when the user navigates away from a page that uses diagram maker to a new page in the application.

動作確認

コードが書けたら、一度ビルドをしてみよう。

$ng serve -o

ブラウザが起動し、ドット状の模様が画面いっぱいに広がって見えたら成功(開発者ツールを開きっぱなしにしているので右側にいろいろ見えてしまっているけど気にしない)。

f:id:tercel_s:20201011170816p:plain

ノードを作る

ここから先は、実際にエディタ上に要素を配置していく。

本節で作る「ノード (Node)」とは、下図の赤枠で囲まれた要素のことであり、いわばデータ構造の主役を成している。

f:id:tercel_s:20201011194753p:plain:w460

上図のダイアグラムを的に解釈すると、データの入った端点と、2つの端点を結ぶ でできている。

このうちの端点をノード (Node)、線をエッジ (Edge) と呼ぶ約束にした、というだけの話である。 堅苦しい言葉の定義が呑み込めなくても雰囲気さえ伝われば問題ない

描画関数の実装

先ほどから実装していた DiagramMakerConfig オブジェクトの renderCallback を書き換える。 これは、公式ドキュメントのサンプルコードからの引用である。

app.component.ts(diff)

    config: DiagramMakerConfig<{}, {}> = {
      renderCallbacks: {
-       node: (node: DiagramMakerNode<{}>, container: HTMLElement) => {},
+       node: (node: DiagramMakerNode<{}>, container: HTMLElement) => {
+         const newDiv = document.createElement('div');
+         const newContent = document.createTextNode(node.id);
+         newDiv.appendChild(newContent);
+         newDiv.classList.add('example-node');
+         if (node.diagramMakerData.selected) {
+           newDiv.classList.add('selected');
+         }
+         container.innerHTML = '';
+         container.appendChild(newDiv);
+       },
        destroy: (container: HTMLElement) => {},
        panels: {}
      }
    };

これは、ノードを <div> タグで包んで、CSSexample-node クラスを当てる DOM 操作である。 さらに、選択状態にあるノードには selected クラスが重ねて当てられる。

次に、これらの CSS クラスの実体を styles.css に書く。 app.component.css ではない点に注意*1

styles.css

.example-node {
  width: 100%;
  height: 100%;

  box-shadow: 1px 1px 3px rgba(0,0,0,0.6);

  background-color: rgba(0,0,0,0.6);
  border-color: #333333;
  border-width: 0.5px;
  border-style: solid;
  border-radius: 3px;

  display: flex;
  justify-content: center;
  align-items: center;

  color: #ffffff;
}

.selected {
  border-color: blue;
  background-color: rgba(0,0,255,0.6);
  color: darkblue;
}

この CSS によって、ノードの外観を自由に変えられる。 色味や影など、試す余裕があるならばいろいろ弄ってみていただきたい。

CSS 解説

CSS がやや長くなったので、念のためここで話をしておこう。

とはいえあまりだらだらやりたくもないので、example-node クラスの中でも特に重要な2点だけに絞る。

まず、縦横のサイズを 100% に設定した。

  width: 100%;
  height: 100%;

これがないとノードが本来のサイズで表示されない。 きちんとサイズ調整しないと、複数のノードをエッジで結合したとき、おかしなところから線が生えているように見えてしまう

サイズ指定なしの場合とありの場合を、それぞれ下図に示す。 サイズ指定なしの場合、<div> タグが縦方向に潰れているし、エッジの付け根の位置も不自然である。

f:id:tercel_s:20201011203909p:plainf:id:tercel_s:20201011203851p:plain
サイズ指定なし(左)とあり(右)

さらに、以下はノード内のテキストを、上下左右中央揃えにするためのイディオムである。

この3行がないと、ノード内のテキストは左上に偏って表示され、見映えが悪くなってしまう。

  display: flex;
  justify-content: center;
  align-items: center;

一般に HTML において上下方向の中央揃えは難しい。 今回は CSS 3 の FlexBox を用いた。

CSS の話はこの辺にしておこう。

ノードデータの定義

表示するノードのデータを DiagramMakerData オブジェクトとして定義する。 以下のように、ノードごとに一意の id と、位置 (position)・サイズ(size) をそれぞれ定義する。

app.component.ts(一部)

  graph: DiagramMakerData<{}, {}> = {
    nodes: {
      node1: {
        id: 'node1',
        diagramMakerData: {
          position: { x: 200, y: 150 },
          size: { width: 100, height: 50 }
        }
      },
      node2: {
        id: 'node2',
        diagramMakerData: {
          position: { x: 400, y: 300 },
          size: { width: 100, height: 50 }
        }
      }
    },
    edges: {},
    panels: {},
    workspace: {
      position: { x: 0, y: 0 },
      scale: 1,
      canvasSize: { width: 3200, height: 1600 },
      viewContainerSize: { width: window.innerWidth, height: window.innerHeight }
    },
    editor: { mode: EditorMode.DRAG }
  };

さらに、ngAfterViewInit() 内の DiagramMaker のコンストラクタ呼び出しを以下のように書き換え、第3引数に渡すようにする。(直接渡すのではなく、一皮かぶせている点に注意)。

app.component.ts(一部)

    ngAfterViewInit(): void {
      this.diagramMaker = new DiagramMaker(
        this.diagramEditorContainer.nativeElement,
-       this.config
+       this.config,
+       { initialData: this.graph }
      );
    /* 略 */
  }

動作確認

ここで今ふたたびの動作確認。

f:id:tercel_s:20201011174857p:plain

驚くべきことに、このノードはマウスでドラッグすることができるし、クリックすれば選択状態になるし、選択したまま Delete キーを押せば削除もできる。

コネクタを作る

ノード同士を結ぶためのコネクタ (Connector) を作る。 言葉で説明しようとするとなんいので手を抜いて図示すると、下図の黒丸ポチの部分がコネクタである。

f:id:tercel_s:20201011181249p:plain:w200

構成設定の修正

DiagramMakerConfigoption プロパティを追加し、以下のようにコーディングする。

app.component.ts (diff)

    config: DiagramMakerConfig<{}, {}> = {
+     options: {
+       connectorPlacement: ConnectorPlacement.LEFT_RIGHT,
+       showArrowhead: true
+     },
      renderCallbacks: {
        /* 略 */
      }
    };

動作確認

ノードにコネクタがついた。

f:id:tercel_s:20201011180347p:plain

しかも、マウスを使えばコネクタからエッジを伸ばし、他のノードと結合できる。

f:id:tercel_s:20201011180416p:plainf:id:tercel_s:20201011180436p:plainf:id:tercel_s:20201011180453p:plain

エッジを作る

エッジデータの定義

再び DiagramMakerData に戻ろう。 からデータになっていた edges プロパティを、以下のように修正する。

app.component.ts

    graph: DiagramMakerData<{}, {}> = {
      nodes: {
        node1: {
          id: 'node1',
          diagramMakerData: {
            position: { x: 200, y: 150 },
            size: { width: 100, height: 50 }
          }
        },
        node2: {
          id: 'node2',
          diagramMakerData: {
            position: { x: 400, y: 300 },
            size: { width: 100, height: 50 }
          }
        },
      },
-     edges: {},
+     edges: {
+       edge1: {
+         id: 'edge1',
+         src: 'node1',
+         dest: 'node2',
+         diagramMakerData: { }
+       }
      },
      panels: {},
      workspace: {
        position: { x: 0, y: 0 },
        scale: 1,
        canvasSize: { width: 3200, height: 1600 },
        viewContainerSize: { width: window.innerWidth, height: window.innerHeight }
      },
      editor: { mode: EditorMode.DRAG }
    };

動作確認

ノード同士がエッジで結ばれた。

f:id:tercel_s:20201011182725p:plain

今日はここまで。

ここまでのまとめ

このようなインタラクションは、ジョブネット構築用のミドルウェアであったり、アプリケーションの画面遷移を定義する StoryBoard だったり、どちらかというと開発者向けのインタフェースでよく見かけたものであった。

非常に効果的な UI だが、いざアプリケーションに組み込もうとすると実装コストがあまりにも高すぎて、常に一点モノになりがちだという問題が付きまとっていた。

今回、AWS が効果的に汎用化・部品化に成功したことで、よりリッチなアプリケーションが増えてユーザ満足度が上がるといいなと思うたーせるなのでした。

あと、一日も早くドキュメントが充実しますように。

おまけ: サンプルコード全文

ここで、本記事で断片的に示してきたサンプルコードの全文を以下に示す。

どうしても現段階ではリファレンスやサンプルが不足しているので、どなたかの参考になれば幸いである。

styles.css

.example-node {
  width: 100%;
  height: 100%;

  box-shadow: 1px 1px 3px rgba(0,0,0,0.6);

  background-color: rgba(0,0,0,0.6);
  border-color: #333333;
  border-width: 0.5px;
  border-style: solid;
  border-radius: 3px;

  display: flex;
  justify-content: center;
  align-items: center;

  color: #ffffff;
}

.selected {
  border-color: blue;
  background-color: rgba(0,0,255,0.6);
  color: darkblue;
}

app.component.html

<div class="container" #diagramEditorContainer>
</div>

app.component.ts

import { AfterViewInit,
         Component,
         ElementRef,
         OnDestroy,
         ViewChild } from '@angular/core';

import { DiagramMaker,
         DiagramMakerConfig,
         DiagramMakerNode,
         DiagramMakerData,
         EditorMode,
         ConnectorPlacement } from 'diagram-maker';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit, OnDestroy {
  diagramMaker: DiagramMaker;

  @ViewChild('diagramEditorContainer') diagramEditorContainer: ElementRef;

  config: DiagramMakerConfig<{}, {}> = {
    options: {
      connectorPlacement: ConnectorPlacement.LEFT_RIGHT,
      showArrowhead: true
    },

    renderCallbacks: {
      node: (node: DiagramMakerNode<{}>, container: HTMLElement) => {
        const newDiv = document.createElement('div');
        const newContent = document.createTextNode(node.id);
        newDiv.appendChild(newContent);
        newDiv.classList.add('example-node');
        if (node.diagramMakerData.selected) {
          newDiv.classList.add('selected');
        }
        container.innerHTML = '';
        container.appendChild(newDiv);
      },
      destroy: (container: HTMLElement) => {},
      panels: {}
    }
  };

  graph: DiagramMakerData<{}, {}> = {
    nodes: {
      node1: {
        id: 'node1',
        diagramMakerData: {
          position: { x: 200, y: 150 },
          size: { width: 100, height: 50 }
        }
      },
      node2: {
        id: 'node2',
        diagramMakerData: {
          position: { x: 400, y: 300 },
          size: { width: 100, height: 50 }
        }
      }
    },
    edges: {
      edge1: {
        id: 'edge1',
        src: 'node1',
        dest: 'node2',
        diagramMakerData: {}
      }
    },
    panels: {},
    workspace: {
      position: { x: 0, y: 0 },
      scale: 1,
      canvasSize: { width: 3200, height: 1600 },
      viewContainerSize: { width: window.innerWidth, height: window.innerHeight }
    },
    editor: { mode: EditorMode.DRAG }
  };

  ngAfterViewInit(): void {
    this.diagramMaker = new DiagramMaker(
      this.diagramEditorContainer.nativeElement,
      this.config,
      { initialData: this.graph }
    );
    window.addEventListener('resize', () => {
      this.diagramMaker.updateContainer();
    });
  }

  ngOnDestroy(): void {
    if (this.diagramMaker) {
      this.diagramMaker.destroy();
    }
  }
}

以上。

*1: app.component.css だと、Angular の CSS カプセル化によってうまくスタイルが適用されなくなってしまうのだ。ちなみにこの話は D3 のときもした。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.