[Astro] #171 DOT Graph Visualizer — Graphviz WASM で DOT を描き、つながりを光らせる

[Astro] #171 DOT Graph Visualizer — Graphviz WASM で DOT を描き、つながりを光らせる

はじめに

前回は、字幕ファイルの変換ツールでした。

今回は、同じ「未開拓アイデア」にあった、DOT Graph Visualizer です。左に DOT を書くと、右に図が出ます。Graphviz を WASM にしたものを使います。

利用枠は、残りわずかでした。そこで、まず軽い骨組みを作って、余裕があれば機能を足す、という進め方にしました。WASM の用意が重ければ、私がビルドする、という約束もしていましたが、結局、要りませんでした。

今回の要点は、次の 5 つです。

  1. WASM が中に入った JS を 1 つ置くだけで、Graphviz が動く
  2. DOT のエラーを、行番号つきで、エディタの上に出す
  3. ドラッグとホイールで、図を動かす。SVG、PNG などで書き出す
  4. 日本語のラベルで、枠が窮屈になる問題に、余白で対処する
  5. ノードをクリックすると、つながっているところだけが光る

完成画面

フローチャートを描いたところ

DOT のフローチャートが描かれた DOT Graph Visualizer

左が DOT のエディタ、中央が図、右がエンジンの選択と書き出しです。行番号があって、エラーのある行は赤くなります。

ノードをクリックしたところ

ノードをクリックして、つながっているノードとエッジが赤く強調された画面

state をクリックしたところです。つながっている 4 つのノードと 4 本のエッジが残って、ほかは薄くなります。

WASM は、JS 1 つを置くだけだった

Astro で WASM を使うときは、配信の設定や、読み込みの方法で、手間取ることがあります。今回も、そこを心配していました。

使ったのは、@viz-js/viz(3.31.0)です。パッケージの dist/viz-global.js は、WASM が中に埋め込まれた 1 ファイル(約 1.3MB)でした。これを public/tool/dot-graph/ に置いて、ページから script タグで読むだけです。WASM を別に配信する必要も、fetch も、Vite の設定も要りません。

<script is:inline src="/tool/dot-graph/viz-global.js"></script>

読み込むと、Viz というグローバルが使えます。使い方は、次のとおりです。

Viz.instance().then(viz => {
  const r = viz.render(src, { format: 'svg', engine: 'dot' });
  // r.status は 'success' か 'failure'。r.output が結果、r.errors が警告とエラー
});

Graphviz のバージョンは 16.1.0 でした。エンジンは 11 個ありますが、画面には、dot、neato、fdp、sfdp、circo、twopi、osage、patchwork の 8 つを出しています。

format を変えるだけで、SVG 以外にも出せます。書き出しの canon(Graphviz が整形した DOT)、json、plain、xdot は、これを使っています。

エラーを、行番号つきで出す

DOT に文法の間違いがあると、render は失敗して、errors に文が入ります。たとえば、b -> ; と書くと、syntax error in line 3 near ';' です。

この文から、行番号を取り出します。

errLine(msg) { const m = /line (\d+)/i.exec(msg || ''); return m ? Number(m[1]) : 0; }

エディタは、textarea の左に、行番号の div を置いた作りです。textarea の横スクロールと縦スクロールに、行番号を合わせます。エラーの行には、半透明の赤い帯を重ねます。行の高さを 18px に固定してあるので、位置は、8 + (行 - 1) * 18 - scrollTop で決まります。

失敗したときは、直前の図を残したままにして、エラーだけを出します。打っている途中で、図が消えてしまうと、使いにくいためです。

図を動かす

図は、SVG の文字列を、そのまま div に入れています。動かすのは、CSS の transform です。

  • ドラッグ:pointerdown から pointermove で、移動量を足す
  • ホイール:カーソルの位置を中心にして、拡大縮小する
function zoomAt(cx, cy, f) {
  const k = Math.min(40, Math.max(0.02, view.k * f));
  const r = k / view.k;
  view.x = cx - (cx - view.x) * r;
  view.y = cy - (cy - view.y) * r;
  view.k = k;
}

Graphviz の SVG は、大きさが pt で書かれています。100% の表示は、pt に 96/72 を掛けた px にしました。ブラウザが普通に表示する大きさです。PNG の 1x も、同じ大きさです。

書き出し

SVG は、文字列をそのまま保存します。PNG は、SVG を Image に読ませて、canvas に描いて、toBlob で取り出しています。倍率は、1 から 4 倍です。大きすぎると失敗するので、幅か高さが 16384px を超えるか、1 億画素を超えるときは、断ります。

背景を透過にする

Graphviz の SVG は、背景を、最初の polygon として、必ず出します。背景を透過にするときは、この polygon を取り除きます。

stripBg(svg) {
  return svg.replace(/(<g id="graph0"[^>]*>\s*<title>[\s\S]*?<\/title>\s*)<polygon[^>]*\/>/, '$1');
}

「白にする」ときは、この polygon の色を白に置き換えます。画面の表示にも、同じ処理で、市松模様の背景を出せます。

日本語のラベルは、窮屈になる

サンプルに、日本語のフローチャートを入れたところ、次の警告が出ました。

no hard-coded metrics for 'sans-serif'. Falling back to 'Times' metrics
no value for width of non-ASCII character 233. Falling back to width of space character

WASM の Graphviz には、フォントがありません。そのため、文字の幅を、内部の表で見積もります。日本語などの文字は、表にないので、幅が概算になります。実際の画面では、「エラー表示」の文字が、枠いっぱいになりました。

幅を正しく測る方法は、見つけられませんでした。そこで、枠の余白を広げることにしました。render の nodeAttributes は、全ノードの「既定値」を渡せます。

const o = { format: 'svg', engine: 'dot' };
if (margin) o.nodeAttributes = { margin: margin };   // 例: '0.25,0.08'

Node で確認したところ、「エラー表示」のノードの幅は、既定で 47.25 でした。余白を 0.3,0.1 にすると、67.67 になりました。DOT の側で、ノードに margin を書いてあれば、そちらが優先されます(そのときの幅は 40.05)。

右のパネルに「Node margin」の選択を置きました。警告は、同じ種類が何回も出るので、1 行にまとめて、余白の使い方を案内しています。

これは、幅を直したのではなく、見た目の逃げです。フォントによっては、まだ、はみ出すことがあります。

ノードのクリックで、つながりを光らせる

大きな図では、あるノードが、どこにつながっているか、目で追うのが大変です。そこで、クリックで強調する機能を付けました。

つながりは、SVG の title から読む

Graphviz の SVG は、ノードを <g class="node">、エッジを <g class="edge"> として出して、どちらにも <title> を付けます。ノードの title は、名前です。エッジの title は、a->b か a--b です(ポート付きは a:p->b:q)。

この title を、ノードの名前の集まりと突き合わせて、from と to に分けます。名前に -> や : が入っていても、読めるようにするためです。

splitEdge(title, nodeSet) {
  const strip = s => {
    if (nodeSet.has(s)) return s;
    for (let i = 0; i < 2; i++) {            // a:port:compass の後ろを落としてみる
      const k = s.lastIndexOf(':'); if (k < 0) break;
      s = s.slice(0, k); if (nodeSet.has(s)) return s;
    }
    return null;
  };
  for (const sep of ['->', '--']) {
    let i = title.indexOf(sep);
    while (i >= 0) {
      const a = strip(title.slice(0, i)), b = strip(title.slice(i + sep.length));
      if (a !== null && b !== null) return { from: a, to: b, directed: sep === '->' };
      i = title.indexOf(sep, i + 1);
    }
  }
  return null;
}

強調のしかたは 3 種類

ノードをクリックしたときの強調は、右のパネルで選べます。

  • neighbors:つながっている、隣のノードと、そのエッジだけ
  • downstream:そこから先(下流)を、全部たどる
  • upstream:そこへ来る側(上流)を、全部たどる

無向グラフは、向きがないので、どちらを選んでも、つながっている範囲が、全部光ります。エッジをクリックしたときは、そのエッジと、両端のノードが光ります。何もない所をクリックするか、Esc を押すと、解除されます。ドラッグで動かしたときは、選択にしません(動いた距離が 4px 未満のときだけ、クリックとして扱います)。

フローチャートのサンプルで、確かめた結果です。

  • check をクリック(neighbors):4 ノード、3 エッジ
  • conv をクリック(downstream):conv、out、end の 3 ノード
  • out をクリック(upstream):6 ノード、6 エッジ

最初は、CSS で付けて、光らなかった

最初は、強調のクラスを付けて、CSS で、薄くしたり、赤くしたりしていました。私の確認用のブラウザ(Chromium)では、動きました。ところが、サイトに組み込んだら、ステータスには「4 nodes / 4 edges」と出るのに、図が、何も変わりませんでした。クリックは当たっていて、見た目だけが付いていなかったことになります。

原因は、特定できていません。サイト側のスタイルとぶつかったのだと思いますが、どれなのかは、調べていません。

直し方は、見た目を、CSS ではなく、!important つきのインライン style で、直接付ける形に変えました。インラインの !important は、スタイルシートの !important にも勝ちます。

const sp = (el, prop, val) => {
  if (val) el.style.setProperty(prop, val, 'important');
  else el.style.removeProperty(prop);
};

試しに、すべての g に opacity:1 !important を当てる意地悪な CSS を入れて、確認用のブラウザで動かしたところ、それでも光りました。サイトに組み込んで、動くことも確認できました。

テスト

DOM が要らない部分は、Node で確認しました。@viz-js/viz の CommonJS 版を使って、次を見ました。

  • エンジンと書き出し形式の一覧
  • エラー文からの行番号の取り出し
  • 日本語のラベルの幅と、nodeAttributes の優先順位

画面は、Playwright で Chromium を動かして、確認しました。

  • 描画、エラー行の表示、サンプルの切り替えとエンジンの連動
  • 7 つの書き出し形式(PNG と SVG は、できたファイルの中身まで)
  • 背景の切り替え、ズーム、ヘルプのダイアログ
  • リロードしたときの、入力の復元
  • クリックの強調(3 つのモード、Esc、ドラッグ、ポート付きのエッジ、無向グラフ、再描画のあとも選択が残ること)

ハマりどころ

1. HTML の {...} は、式として読まれる

前回と同じです。ヘルプの中の DOT の例に、波括弧が入るので、&#123; と &#125; に書き換えました。

2. CSS は、最初から is:global に書いた

前回は、スコープ付きの <style> が、あとから作った要素に効かなくて、書き直しました。今回は、最初から、<style is:global> に、すべて #app の下に限定して書きました。この問題は、出ませんでした。

3. 強調の見た目が、サイトで効かなかった

上に書いたとおりです。確認用のブラウザで動いても、サイトに入れたら動かないことが、あります。サイトに入れて確認するまでが、動作確認です。

未確認のこと・未対応のこと

  • 数百から数千ノードの図での、描画の速さ:測っていません。描画は、メインスレッドで、同期して行うので、重いと画面が止まります。Web Worker に移すのが、最初の候補です
  • ノード名や、ラベルでの検索:ありません
  • DOT のシンタックスハイライト:ありません
  • 属性(rankdir や splines など)を、画面から選んで、DOT に書き込む機能:ありません
  • 日本語の文字幅:余白で逃げているだけで、正しく測ってはいません
  • 画像(image=)の外部参照は、PNG の書き出しで読めません

おわりに

WASM は、配信や設定で手間取ると思っていましたが、JS が 1 つで済んだので、拍子抜けでした。ビルドを自分でやる必要もありませんでした。

一方で、手間取ったのは、WASM の外側でした。日本語の幅と、サイトの CSS です。どちらも、ブラウザで開いて、初めて分かることでした。

実装メモは、docs/dot-graph-visualizer.md に、残してあります。次にやるなら、Web Worker での描画と、ノードの検索です。