はじめに
d3.jsでUMLのクラス図用の矢印を作ってみたのでメモ。この記事はほとんどの部分がd3.jsというより単にSVGの話なのでSVGタグも付けておきました。
スクリーンショット
Chromeで等倍表示のスクリーンショット
Chromeで500%に拡大したスクリーンショット
ソース
<!DOCTYPE html>
<head>
<meta charset='utf-8'>
<script src="http://cdnjs.cloudflare.com/ajax/libs/d3/3.4.10/d3.min.js"></script>
</head>
<body>
<div id='example'></div>
<script>
var svg = d3.select('#example').append('svg')
.attr({
width: 50,
height: 100
});
var defs = svg.append('defs');
defs.append('marker')
.attr({
'id': 'filledTraiangle',
viewBox: '0 0 10 10',
'refX': 10,
'refY': 5,
'markerWidth': 10,
'markerHeight': 10,
'orient': 'auto'
})
.append('path')
.attr({
d: 'M10 5 0 0 0 10Z',
'fill-rule': 'evenodd',
stroke: 'none',
fill: 'black'
});
defs.append('marker')
.attr({
'id': 'triangle',
viewBox: '0 0 10 10',
'refX': 10,
'refY': 5,
'markerWidth': 10,
'markerHeight': 10,
'orient': 'auto'
})
.append('path')
.attr({
d: 'M10 5 0 0 0 10 Z M8 5 1 8.4 1 1.6Z',
'fill-rule': 'evenodd',
stroke: 'none',
fill: 'black'
});
defs.append('marker')
.attr({
'id': 'arrowhead',
viewBox: '0 0 10 10',
'refX': 10,
'refY': 5,
'markerWidth': 10,
'markerHeight': 10,
'orient': 'auto'
})
.append('path')
.attr({
d: 'M10 5 0 10 0 8.7 6.8 5.5 0 5.5 0 4.5 6.8 4.5 0 1.3 0 0Z',
stroke: 'none',
fill: 'black'
});
defs.append('marker')
.attr({
id: 'diamond',
viewBox: '0 0 16 10',
refX: 16,
refY: 5,
markerWidth: 16,
markerHeight: 10,
orient: 'auto'
})
.append('path')
.attr({
d: 'M-1 5 7.5 0 16 5 7.5 10Z M1.3 5 7.5 8.7 14 5 7.5 1.3Z',
'fill-rule': 'evenodd',
stroke: 'none',
fill: 'black'
});
defs.append('marker')
.attr({
id: 'filledDiamond',
viewBox: '0 0 16 10',
refX: 16,
refY: 5,
markerWidth: 16,
markerHeight: 10,
orient: 'auto'
})
.append('path')
.attr({
d: 'M-1 5 7.5 0 16 5 7.5 10Z',
stroke: 'none',
fill: 'black'
});
var arrowheads = svg.selectAll('marker')[0].map(function(d, i) {
return {
line: [
{x: 10, y: 20 * i + 10},
{x: 40, y: 20 * i + 10}
],
'marker-end': d.id,
text: d.id
};
});
var line = d3.svg.line()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
svg.selectAll('path.connector')
.data(arrowheads).enter().append('path')
.attr({
'class': 'connector',
'd': function(d) { return line(d.line); },
'stroke': 'black',
'stroke-width': 1,
'fill': 'none',
'marker-end': function(d) { return 'url(#' + d['marker-end'] + ')'; },
});
svg.selectAll('path.connector')
.attr({
'stroke-dasharray': function(d) {
var path = d3.select(this),
totalLength = path.node().getTotalLength(),
marker = svg.select('#' + d['marker-end'])[0][0],
markerWidth = marker.markerWidth.baseVal.value;
return '' + (totalLength - markerWidth) + ' ' + markerWidth;
},
'stroke-dashoffset': 0
});
</script>
</body>
</html>
MITライセンスとします。
試行錯誤メモ
線の端点とマーカの配置について
クラス図ではなく折れ線グラフの場合は線の端点の座標をマーカの中心にして、線の上にマーカを重ねて描画すればよくて、これはSVGで特に工夫なしで実現できます。
一方クラス図の場合は、上のスクリーンショットの例で言うと線の端点の座標がマーカの右端になるようにしています。理由はマーカがクラスの長方形にめり込むのを回避するためです。
マーカの左端を線の端点にするという案も浮かんだのですが、線の端点の座標をクラスの長方形からマーカ長だけ離して指定する必要があります。マーカ長はマーカによってマチマチでこれだと利用時に面倒なのでボツにしました。
マーカの部分に線がかぶらないための調整
線を端点の座標まで引いてしまうと、塗りつぶしではなく内部が空白になっているマーカや三角形のマーカの端点の尖った部分に線がかぶって残念な見た目になってしまいます。
stroke-dasharrayを使う方法を利用
今回の実装ではSVG - d3.js超初心者向け ①→②を表現してみる - Qiitaで紹介されていた stroke-dasharray で調整する方法を使っています。これは実線部分と空白部分の長さを交互に指定するのですが、線長を計算して最後のマーカ部分だけを除いた部分を実線部分、最後のマーカ部分を空白部分としています。
ただ、この方式はマーカ以外の部分の線を実線ではなく点線や破線にしたい場合に面倒なことになります。線長 - マーカ長の範囲にわたって実線部分と空白部分を繰り返して指定する必要があるからです。
stroke-dasharrayではなくマスクを使う方法はもっと面倒そう
マスクで出来ないかと試してみると以下のことが判りました。
- マーカの定義にmaskを指定しても、本体の線のほうには適用されない
- 本体の線はマスクされないので、結果的にマーカ上に線がかぶってしまう
- 本体の線にマスクを設定すると、マーカの描画にも適用されてしまう
どちらも妥当な挙動ではあります。今回の目的にはstroke-dasharrayを使ってマーカに被らないようにした線とマーカを合わせたマスクを作って、stroke-dasharray指定無しの線とマーカにそのマスクを適用するという手法が必要ということになります。が、面倒なのでこれは試していません。
マーカの書き方
マーカのmarkerWidth, markerHeight, viewBoxについてはsvg要素の基本的な使い方まとめの図がわかりやすいです。
まずstroke方式を試した
最初は何も考えずにビューボックス上の点の座標を指定してstrokeで書いてみましたが、ビューボックスのクリッピングに引っかかって残念な見た目になりました。
線の太さとstroke-linejoinでmiterにした角の部分があるので、その分だけビューボックスの内側に書く必要があります。
本体の線のstroke-dasharrayがmarkerにも効いてしまうことが判明
中抜きの三角形のマーカを書いてみると線が途中までしか描画されませんでした。本体の線のstroke-dasharrayの指定を外すとマーカも最後まで描画されます。本体のstroke-dasharrayが本体の線長に対してマーカの線長で比例配分して適用されているっぽいです。
Chrome 36ではマーカに stroke-dasharray="0"
を指定することで回避出来ました。が、Safari 6.1.5では回避できませんでした。
fill方式に切り替え
そこでstrokeはnoneにしてfill方式に切り替えました。中抜きのマーカは
fill-ruleで"evenodd"にしてパスを外側と内側で逆回りにして実現しています。
本来はマーカの線の太さが希望の値になるように、パスの各点の座標を計算すべきなのですが、面倒なのでブラウザで最大ズームで見ながら太さがいい感じになるよう座標を調整しました。
ダイアモンドマーカと本体の線の繋ぎ目が良い感じになるように調整
これでマーカは角が綺麗に尖っていい感じになったのですが、ダイアモンドのマーカの場合は本体の線との繋ぎ目が切れている感じに見えてしまいイマイチでした。そこでビューボックスから本体の線側にはみ出してマーカを書くようにしました。
矢尻のマーカには本体の線の延長部を含めた
矢尻のマーカ (スクリーンショットの3番目) はstrokeで書いてもSafariでも欠けなかったのでstrokeで書いています。
本体の線はmarkerWidth分を残して止めるので、そこからマーカの端点までの線はマーカ側で書くようにしました。
ただ、矢尻の線の端がstroke-linejoinの図の下側のようにY軸に対して少し斜めになるので、Y軸方向にきっちり揃えたい場合は、strokeを止めて矢尻と本体の線の繋ぎを含めた部分の縁取りのpathを作ってfillすればよさそうです。
が、上記のスクリーンショットを見てこれで十分かと思ったので、今回はそこまではやらないことにしました。
追記: と思ったのですが、やっぱりfill方式にしてみました。ソースとスクリーンショットを差し替え済みです。