このブログの比較記事には、製品ごとに5つの軸で点数を付けたレーダーチャートを載せている。あの図は表計算ソフトやグラフ用のライブラリで描いたものではなく、awkのスクリプト1本が直接書き出したSVGだ。作り始めた頃の制作用の環境には、PythonもNode.jsも入っていなかった。それでもWindowsにGit for Windowsを入れておけば、付属のGit Bashでawkはすぐに使える。図の形はほぼ決まっていて、変わるのは製品名と色と5つの点数だけなので、それなら座標を自分で計算して文字として書き出せばよい、と考えた。

このメモでは、そのスクリプトが何をしているのかを、座標の計算から順に記録しておく。あわせて、点数に想定外の値を渡したときや、タイトルに記号を入れたときに図がどう壊れるかも試した。確認に使ったのはGit Bashに入っているGNU Awk 5.4.0だ。書いてから時間がたつと、どの数字が何のためにあるのかを自分でも忘れてしまうので、その備忘録も兼ねている。

awkとSVG、どちらもテキストを書くだけの道具

ブライアン・カーニハンが壇上の机に向かい、マイクの前で笑顔を見せている写真
awkの名前の「k」に当たるブライアン・カーニハン。2023年に出た第2版の著者にも名を連ねているPhoto: Jw12321 / CC0

awkは1977年に、ベル研究所のアルフレッド・エイホ、ピーター・ワインバーガー、ブライアン・カーニハンの3人が作った言語で、名前は3人の頭文字を並べたものだ。本来は、テキストを1行ずつ読み、欄に分けて集計や整形をするための道具として生まれた。3人による解説書『The AWK Programming Language』は1988年に初版が出て、2023年には35年ぶりに第2版が出ている。その第2版に合わせて、カーニハン自身が手を入れている実装にもUTF-8の扱いが加わった。古い道具ではあるが、いまも手入れが続いている。

一方のSVGは、図形をXMLの文字で記述する画像形式で、W3Cの勧告になったのは2001年だ。円なら中心と半径、多角形なら頂点の座標を並べた文字列を書くだけで、ブラウザがそれを描いてくれる。画像を作るといっても、やることは決まった書式の文字を出力することに尽きる。文字を整形して出すのはawkが最も得意とするところなので、この2つは相性が良い。

ふつうのawkのプログラムは入力ファイルを1行ずつ読むが、レーダーチャートのスクリプトは読むべき入力を持たない。そこで、処理をすべてBEGINブロックに書いている。BEGINだけでできたプログラムは、ブロックを実行し終えるとそのまま終了し、標準入力を待たない。値の受け渡しには-vを使う。-vで渡した変数はBEGINの実行より前に代入されるので、ブロックの中ですぐに使える。ファイル名の後ろに変数=値と書く方法もあるが、こちらはBEGINより後に代入されるので、このスクリプトでは使えない。

awk -v TITLE="製品名" -v COLOR="#2F9E44" -v SCORES="4,3,5,2,4" \
    -v LAB0="セットアップ" -v LAB1="操作性" -v LAB2="音質" \
    -v LAB3="拡張性" -v LAB4="耐久性" \
    -f scripts/radar-chart.awk > static/images/radar/sample.svg

SCORESは5つの点数をカンマでつないだ1つの文字列で、スクリプトの中でsplit(SCORES, s, ",")で配列に分ける。splitは分けた個数を返し、配列の添字は1から始まる。座標の計算は軸の番号を0から数えているので、点数を取り出すときはs[i+1]と1つずらしている。

正五角形の頂点は、上から時計回りに72度ずつ

畑の向こうに低層の大きな研究所の建物が横に長く並ぶ白黒写真
ニュージャージー州マレーヒルのベル研究所。awkはここで生まれたPhoto: Gottscho-Schleisner Collection / Public domain

レーダーチャートの骨組みは、中心から5本の軸が等しい角度で伸びた形だ。360度を5で割ると72度なので、軸の向きは72度ずつずれていく。最初の軸を真上に置くと、図の見た目が安定する。

ここで気を付けるのが、SVGの座標の向きだ。SVGの原点は図の左上にあり、x軸は右へ、y軸は下へ向かって増える。数学の教科書で見るグラフとはy軸が逆なので、真上を指す向きは角度でいうと-90度になる。もう1つ、y軸が下向きのため、角度を増やしていくと点は画面上で時計回りに進む。教科書の座標なら反時計回りに進むところが、そのまま画面に描くと逆回りになる。真上から時計回りに軸を並べたいので、この性質は都合が良い。

軸ごとの向きは、長さ1の矢印(単位ベクトル)として、x方向にcos、y方向にsinの値を持たせる。i番目の軸の角度は「-90度+72度×i」で、計算すると次の表のとおりになる。

軸角度x方向(cos)y方向(sin)図の上での位置
0-90度0.0000-1.0000真上
1-18度0.9511-0.3090右上
254度0.58780.8090右下
3126度-0.58780.8090左下
4198度-0.9511-0.3090左上

いま使っているスクリプトは、この10個の数字を小数点以下4桁で直接書き込んでいる。軸が5本から変わらないなら、毎回三角関数を呼ぶより表を持つほうが読みやすいと考えたからだ。awkには円周率の定数が無いので、自分で計算するならatan2(0, -1)を使う。atan2は点(-1, 0)の方向の角度を返す関数で、その値がちょうど円周率になる。手元で15桁表示すると3.141592653589793で、そこから求めたcosとsinを4桁に丸めると、上の表と1桁も違わなかった。

点の座標は、中心の座標に「半径×点数÷5×単位ベクトル」を足して求める。中心は(250, 250)、点数5に当たる外周の半径は140にしている。たとえば点数4の0番目の軸なら、xは250+140×0.8×0=250、yは250+140×0.8×(-1)=138になる。yが中心より小さいので、図の上ではちゃんと上に描かれる。

1枚のSVGを、背景から順に重ねて書く

awkのスクリプトで書き出したレーダーチャートのサンプル。セットアップ4、操作性3、音質5、拡張性2、耐久性4の緑色の五角形

上の図は、セットアップ4・操作性3・音質5・拡張性2・耐久性4という点数を渡して、そのまま書き出したものだ。SVGでは後から書いた要素ほど手前に描かれるので、スクリプトは奥にあるものから順に出力している。順番は次のとおりで、書き出された要素を数えると25個、ファイルの大きさは2,500バイト台に収まった。

  1. 背景の角丸の長方形(rect)を1つ。viewBoxは「0 0 500 460」で、左上を原点に幅500・高さ460の描画領域を宣言している
  2. 上部のタイトル(text)を1つ。text-anchor="middle"で、x=250を文字列の中心に合わせる
  3. 目盛りの五角形(polygon)を5つ。半径を5分の1から5分の5まで変え、外周だけ少し濃い線にする
  4. 中心から外周の頂点へ向かう軸の線(line)を5本
  5. 軸の名前(text)を5つ
  6. 点数を結んだ五角形(polygon)を1つ。塗りの不透明度を0.28にして、下の目盛りが透けて見えるようにする
  7. 頂点の丸印(circle)を5つ

polygonは、頂点の座標を空白区切りで並べるだけで、最後の点から最初の点まで自動で線を閉じてくれる。最初の点を末尾にもう一度書く必要は無い。点数の五角形にはstroke-linejoin="round"を付けて、頂点の角を丸めている。線の太さが2.5あるので、尖ったままだと鋭角の頂点で線が外側へ突き出して見えるのを避けるためだ。

手間がかかったのは軸の名前の位置だった。文字は外周より少し外の半径178の位置に置くが、そのままだと文字列が軸の線にかぶる。そこで軸ごとに、文字の揃え方と少しのずらし量を決めてある。真上の軸は中央揃えで10だけ上へ、右側の2本は左揃えにして右へ10、左側の2本は右揃えにして左へ10ずらす。text-anchorのstartとendは、指定した座標を文字列の始まりにするか終わりにするかの指定なので、右側の名前は外へ向かって伸び、左側の名前も外へ向かって伸びる。日本語の文字幅を自分で測らなくても、揃える側さえ決めれば中心からはみ出さずに並ぶ。

数値を文字にするとき、%.1fを通すかどうか

DECのVT100端末。ベージュの筐体の画面と、黒いキーが並ぶキーボード
文字の端末で図を作るという発想は、この頃の画面から変わっていないPhoto: Wolfgang Stief from Tittmoning, Germany / CC0

座標の数値を文字列につなぐとき、スクリプトはsprintf("%.1f,%.1f ", px, py)で必ず小数第1位までの形にそろえている。awkでは、数値をそのまま文字列とつなげると、変数CONVFMTの書式で文字に変換される。初期値は「%.6g」で、有効数字6桁という意味だ。1番目の軸の点数4のx座標で比べると、そのままつないだときは「356.518」、%.1fを通すと「356.5」になった。整数になる値はもっと差が出て、%.6gでは「250」、%.1fでは「250.0」と、桁数そのものが変わる。

どちらでも図としては正しく描かれる。ただ、桁がそろっていないと、書き出したSVGを差分で見比べるときに読みにくい。試しにsprintfを外して直接つなぐ形に書き換えると、同じ点数の図が2,542バイトから2,609バイトへ67バイト増えた。小数第1位で丸めたことで生まれる誤差は、描画領域の単位で最大0.05だ。記事の中では幅220ピクセル前後に縮めて表示することが多く、そのときの誤差は0.02ピクセル程度なので、見た目には影響しない。ファイルが小さくなり、差分も読みやすくなるので、丸めておくほうが扱いやすい。

なおCONVFMTは自分で書き換えることもでき、たとえば「%.2f」にすると、つないだ結果は「356.52」になった。とはいえ、スクリプト全体の変換規則を変えるより、座標を出す箇所でsprintfを明示するほうが、後で読んだときに意図が分かりやすいと思う。

想定外の入力で、図はどう壊れるか

このスクリプトは、渡された値の確認をほとんどしていない。点数は1から5の整数、タイトルはふつうの文字列、という前提で書いてある。その前提を外したときに何が起きるかを、1つずつ試した。

入力起きたこと
点数に6を渡す頂点のy座標が82になり、外周(110)を28はみ出して描かれる
点数に0を渡す頂点が中心(250, 250)に重なり、五角形が欠けた形になる
点数を2つしか渡さない残り3つの軸が空の値として0扱いになり、中心に集まる。エラーは出ない
点数に-1を渡す頂点が中心をはさんで反対側へ飛び、左下の軸の点が右上に描かれる
タイトルに「R&B 」を渡すXMLとして読めないSVGになり、画像として表示されない

いちばん気付きにくいのは、点数が足りないときと、マイナスの値のときだ。awkは存在しない配列の要素を空の文字列として扱い、計算では0になるので、何の警告も出さずに図を書き出してしまう。マイナスの値は「半径×点数」がマイナスになるので、軸と逆向きに点が置かれる。どちらも図としては描かれるため、一見すると変な形のチャートに見えるだけで、入力の誤りだとは気付きにくい。範囲を守らせたいなら、点数を取り出したところで0未満を0に、5より大きい値を5にそろえる小さな関数を1つ挟めば足りる。

タイトルの記号は、もっとはっきり壊れる。SVGはXMLなので、文字の中に&や<がそのまま入ると、その時点で文書として読めなくなる。手元で書き出したファイルをXMLとして解析させると、3行目のタイトルの&のところで止まり、エラーになった。ブラウザの<img>で表示すると、壊れた画像の印になる。製品名には「&」を含むものが案外あるので、文字を埋め込む前に置き換えておくほうが安全だ。

function esc(t) {
    gsub(/&/, "\\&amp;", t)
    gsub(/</, "\\&lt;", t)
    gsub(/>/, "\\&gt;", t)
    return t
}

ここで1つつまずいた。gsubの置き換え先の文字列では、&は「見つかった文字そのもの」という意味を持つ。文字としての&を出すには\\&と、逆斜線を2つ重ねる必要がある。最初、逆斜線を1つだけにして試したところ、awkは警告を出したうえで、1行目の&amp;だけは正しく置き換えた。見つかった文字がちょうど&だったので、偶然同じ結果になったのだ。ところが2行目以降は<が残ったまま後ろに「lt;」が付き、「<lt;test>gt;」という形になった。1行目が合っていると全体も合っているように見えるので、記号を3種類とも入れて確かめないと見落とすところだった。

もう1つ、-vで渡した文字列は、逆斜線の並びが特別な文字として解釈される。-v T='a\tb'と渡すと、変数の中身は「a」とタブと「b」になった。Windowsのフォルダの区切りのような逆斜線を含む文字列をタイトルに使うことはまず無いが、そういう値を渡すときはENVIRON経由で環境変数から読むほうが確実だ。

軸の数を変えられるようにする

5本の軸の向きを表で持つやり方は読みやすい反面、6軸や8軸の図が欲しくなったときに表を作り直すことになる。atan2で円周率を求め、軸の数で360度を割るように書き換えると、点数の個数がそのまま軸の数になる。

BEGIN {
    pi = atan2(0, -1)
    n = split(SCORES, s, ",")
    for (i = 0; i < n; i++) {
        a = -pi / 2 + 2 * pi * i / n
        ux[i] = cos(a); uy[i] = sin(a)
    }
}

splitの戻り値を軸の数として使うところがこの形の要点で、SCORESに6つの値を渡せば60度刻みの六角形になる。同じ「4,3,5,2,4」で頂点の座標を計算させると、表を使う版と小数第1位まで同じ5つの座標が出た。6つの値を渡したときは、2番目の軸が右下の(371.2, 320.0)、3番目が真下の(250.0, 306.0)になり、六角形の頂点が想定どおりの位置に並んだ。

ただし、軸の数を可変にすると、軸の名前の揃え方も角度から決める必要が出てくる。xの向きが正なら左揃え、負なら右揃え、ほぼ0なら中央揃え、とux[i]の値で分ければ、5軸のときに手で決めていた配置とほぼ同じになる。いまのブログでは5軸しか使っていないので、スクリプトは表を持つ形のまま残し、可変版は手元のメモにとどめている。

日本語の名前とフォントの扱い

軸の名前やタイトルには日本語を使っている。-vで渡した日本語は、awkの中では文字の並びとしてそのまま扱われ、書き出したSVGにも同じ形で入る。座標の計算に文字数は使っていないので、文字コードの扱いで困ることは無かった。

フォントはfont-familyに、Macで使われるヒラギノ角ゴシック、Noto Sans JP、最後に一般的なゴシック体の指定を並べている。記事の中ではこのSVGを<img>として埋め込んでいるので、ページ側で使っているWebフォントは図の中には届かない。画像として扱われるSVGは、外部の資源を取りに行けないからだ。そのため、図の文字は見る人の端末に入っているフォントで描かれる。Windowsではヒラギノもたいてい入っていないので、最後のゴシック体の指定から、端末に標準で入っている和文フォントが選ばれる。端末によって文字の幅が少し変わるが、揃える側を決めてあるので、名前が線に重なったり図の外へはみ出したりすることは無かった。

最後に、Git Bashの上で同じ図を50回続けて書き出したところ、合計で1.4秒ほどだった。1枚あたり30ミリ秒弱で、そのほとんどはawkを起動する時間だと思う。比較記事1本分の5枚なら、待つという感覚にはならない。

まとめ

項目このスクリプトでのやり方
入力-vで渡し、BEGINだけで処理する。標準入力は読まない
軸の向き真上(-90度)から72度ずつ。y軸が下向きなので時計回りに並ぶ
円周率定数が無いので、使うならatan2(0, -1)
座標の書式sprintfの「%.1f」で小数第1位にそろえる
重ね順背景、タイトル、目盛り、軸、名前、点数の五角形、丸印の順
壊れやすい入力範囲外や不足した点数は警告なしで描かれ、記号はXMLを壊す
対策点数を0から5に収める関数と、&・<・>を置き換える関数を挟む

ライブラリを使わずに図を描くと、座標の向きや文字の書式といった、ふだんは道具任せにしているところを1つずつ自分で決めることになる。そのぶん、図が崩れたときにどこを見ればよいかがすぐ分かる。25個の要素と80行足らずのawkで足りるなら、この形の図にはこれで十分だと感じている。

関連記事