先日、AWS が Diagram Maker という画期的な UI ライブラリを公開しました。
ダイアグラムエディタ機能を簡単にアプリケーションに組み込むことができるらしいです。
でも残念ながらリリースされたばかりで、チュートリアルや解説記事がまだまだ少ない状況なんだよね。
そんなわけで、ちょっと触ってみて、できたところまでチュートリアルにしようと思いました。
それがこの記事です。
はじめに
先日、フロントエンド界隈でGigazine さんの記事が俄かにバズった。 あの AWS が、ダイアグラムエディタを実装するライブラリ「Diagram Maker」を公開したという。
ライブラリの紹介については当該記事に譲るとして、さっそく中身を触ってみることにした。 それもよりによって、React や Vue ではなく Angular と合わせて使ってみようと考えた。
つい先日 #AWS がリリースして話題になった Diagram Maker をためしてる。
— たーせる (@tercel_s) 2020年10月10日
ひねくれものなので、React ではなく #Angular ( #TypeScript ) と組み合わせている。
公式ドキュメントがあまり親切ではなく、ソースを読解しながらちょっとずつ進んでいて進捗わるい……。とりあえずここまで。 pic.twitter.com/J53vhP8sr8
完成(予定)図
今日は長丁場になることが予想されるので、モチベーションを上げるため先にゴールを示しておこう。
静止画でお伝えできないのが残念だが、ドラッグ & ドロップでいろんなところがぐりぐり動かせる。
こんなインタラクティブなコンポーネントがアプリケーションに組み込めたら、よりユーザフレンドリーなシステムが実現できるかもしれない。
開発環境について
本記事の内容は 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 で開くと以下のように見える(矢印と枠線は筆者による加筆)。
薄い青色が <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
ブラウザが起動し、ドット状の模様が画面いっぱいに広がって見えたら成功(開発者ツールを開きっぱなしにしているので右側にいろいろ見えてしまっているけど気にしない)。
ノードを作る
ここから先は、実際にエディタ上に要素を配置していく。
本節で作る「ノード (Node)」とは、下図の赤枠で囲まれた要素のことであり、いわばデータ構造の主役を成している。
上図のダイアグラムを幾何的に解釈すると、データの入った端点と、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>
タグで包んで、CSSの example-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>
タグが縦方向に潰れているし、エッジの付け根の位置も不自然である。
さらに、以下はノード内のテキストを、上下左右中央揃えにするためのイディオムである。
この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 } ); /* 略 */ }
動作確認
ここで今ふたたびの動作確認。
驚くべきことに、このノードはマウスでドラッグすることができるし、クリックすれば選択状態になるし、選択したまま Delete キーを押せば削除もできる。
コネクタを作る
ノード同士を結ぶためのコネクタ (Connector) を作る。 言葉で説明しようとすると難儀いので手を抜いて図示すると、下図の黒丸ポチの部分がコネクタである。
構成設定の修正
DiagramMakerConfig
に option
プロパティを追加し、以下のようにコーディングする。
app.component.ts
(diff)
config: DiagramMakerConfig<{}, {}> = { + options: { + connectorPlacement: ConnectorPlacement.LEFT_RIGHT, + showArrowhead: true + }, renderCallbacks: { /* 略 */ } };
動作確認
ノードにコネクタがついた。
しかも、マウスを使えばコネクタからエッジを伸ばし、他のノードと結合できる。
エッジを作る
エッジデータの定義
再び 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 } };
動作確認
ノード同士がエッジで結ばれた。
今日はここまで。
ここまでのまとめ
このようなインタラクションは、ジョブネット構築用のミドルウェアであったり、アプリケーションの画面遷移を定義する 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(); } } }
以上。