[Astro] #171 DOT Graph Visualizer — Graphviz WASM で DOT を描き、つながりを光らせる
はじめに
前回は、字幕ファイルの変換ツールでした。
[Astro] #170 Subtitle Converter — SRT / VTT / ASS / SBV / LRC の変換とズレ補正(SJIS対応)
字幕変換ツールを作りました。
lain-lab.com今回は、同じ「未開拓アイデア」にあった、DOT Graph Visualizer です。左に DOT を書くと、右に図が出ます。Graphviz を WASM にしたものを使います。
利用枠は、残りわずかでした。そこで、まず軽い骨組みを作って、余裕があれば機能を足す、という進め方にしました。WASM の用意が重ければ、私がビルドする、という約束もしていましたが、結局、要りませんでした。
今回の要点は、次の 5 つです。
- WASM が中に入った JS を 1 つ置くだけで、Graphviz が動く
- DOT のエラーを、行番号つきで、エディタの上に出す
- ドラッグとホイールで、図を動かす。SVG、PNG などで書き出す
- 日本語のラベルで、枠が窮屈になる問題に、余白で対処する
- ノードをクリックすると、つながっているところだけが光る
完成画面
フローチャートを描いたところ
左が 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 の例に、波括弧が入るので、{ と } に書き換えました。
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 での描画と、ノードの検索です。