SVG の <circle> を <path> で描く

Fork me on GitHub
Hover a red circle to show its coordinate. {{ p.text }} {{ line }}
アルゴリズム:
  • (説明)
  • (説明)
  • (説明)
図形:
半径:
  • X:
  • Y:
端点・制御点を隠す

目次

これは何?

HTML5 上であれば <circle> タグを使えば円を描くことは出来ますが、 SVG の <path> タグの d 属性に指定できるパス文字列の形式で表すことで
svg要素の基本的な使い方まとめ
などに移植できるよう汎用性を持たせることができます。

…というのは調べて分かった建前で、ふと必要になったから調べただけです。

注意

何も考えずに ES6 を使って書いたら最新版の Chrome と Firefox で動いてしまったので ES5 で書き直すモチベーションが失われてしまいました。 すみませんが、うまく表示されなかった場合は Chrome 58.0.3029.110 以上 または Firefox 53.0.3 以上 を使ってください。あしからず。

アルゴリズムについての解説

誤差 (=円の歪み) に関しては以下の認識です (上に行くほど誤差が発生する)。
  1. 8本の2次ベジェ曲線で描画
  2. 4本の3次ベジェ曲線で描画
  3. 2本の円弧で描画
測定をした訳ではないので間違っているかもしれません、すみません… (まとめただけで数学的な証明や誤差の測定などの検証はしていません)。 ただ2本の円弧に関してはベジェ曲線と違って本当の円なので誤差はないはずです。

2本の円弧で描画

Paths - SVG | MDN から引用。

A rx ry x-axis-rotation large-arc-flag sweep-flag x y
今回は x-axis-rotation, large-arc-flag sweep-flag は今回は無視して構いません。 ただ2本の曲線とも同じ値を渡す必要はあります (0,1,0 とか)。 rx,ry はそれぞれ縦と横の半径、x,y は終点の座標です。

上部分と下部分に分けて描画します (左右でもいい)。 ここでは横の半径が 40、縦の半径が 30 の楕円を描きます (rx=40, ry=30)。 現在の位置と x, y (終点) と縦横の半径の整合性が取れてないと正しく描画されません。

上部分

M10,10 A40,30 0,1,0 90,10

下部分

M90,40 A40,30 0,1,0 10,40

実際のコードが生成するパスは以下のようになります (CX,CY = 円の中心の X,Y 座標)。

M (CX - rx) CY
A rx ry 0 1 0 (CX + rx) CY
A rx ry 0 1 0 (CX - rx) CY

実際のコードは以下の通りです (該当の箇所)。 ここではパスを表す二次元配列を返しています。全て半角空白かカンマで join すると SVG のパス文字列が出来上がります。 param.figureが "circle" なら真円で、"ellipse" なら楕円のパス文字列を生成します。

  drawCircle(param) {
    const rx = param.rx;
    const ry = param.figure === 'circle' ? rx : param.ry;
    return [
      ['M', CX - rx, CY],
      ['A', rx, ry, 0, 1, 0, CX + rx, CY],
      ['A', rx, ry, 0, 1, 0, CX - rx, CY],
    ];
  }

4本の3次ベジェ曲線で描画

Illustrator でも使われているらしい方法。

Illustratorの楕円ツールで書く円は、本当は円ではなくて、近似値なんです。つまり、円としてはわずかに歪んでいるわけ。これはベジェ曲線というものを使う以上、そういう仕様なのですね。 イラレの円は本当は円じゃない(もしくは時空の裂け目について) - 遠近法ノート

なぜ4本かというと

180度ぐらいから一気に誤差が増える。180度でも2%近い誤差なので、90度ぐらいで分割するほうが無難だろう。 11:ベジェ曲線で円を描けるか – Programming Cit
とのこと。

Paths - SVG | MDN から引用。

C x1 y1, x2 y2, x y
x1,y1 は曲線の始点向けの制御点の座標、x2,y2 は曲線の終点向けの制御点の座標、x,y は終点の座標です。 よく分かってないので制御点って何よ?って聞かないでください。

左上、右上、右下、左下部分に分けて描画します。 2本の円弧で描画と同じく横の半径が 40、縦の半径が 30 の楕円を描きます (rx=40, ry=30)。

左上部分

※見やすさのために第2小数点までで切り捨ててます。
M10,40 C10,23.43 17.90,10 40,10

右上部分

M10,10 C32.09,10 50,23.43 50,40

右下部分

M50,10 C50,26.56 32.09,40 10,40

左下部分

M50,40 C27.90 40 10 26.56 10 10 実際のコードが生成するパスは以下のようになります。
M (CX - rx) CY
C (CX - rx) (CY - KAPPA * ry) (CX - KAPPA * rx) (CY - ry) CX (CY - ry)
C (CX + KAPPA * rx) (CY - ry) (CX + rx) (CY - KAPPA * ry) (CX + rx) CY
C (CX + rx) (CY + KAPPA * ry) (CX + KAPPA * rx) (CY + ry) CX (CY + ry)
C (CX - KAPPA * rx) (CY + ry) (CX - rx) (CY + KAPPA * ry) (CX - rx) CY

ここで KAPPA という謎の変数が出現しますが、これは 0.55228... (= (-1 + sqrt(2)) / 3 * 4) という値です。 詳しくは

を参照してください。

実際のコードは以下の通りです (該当の箇所)。

  drawCircle(param) {
    const rx = param.rx;
    const ry = param.figure === 'circle' ? rx : param.ry;
    return [
      ['M', CX - rx, CY],
      ['C', CX - rx, CY - KAPPA * ry, CX - KAPPA * rx, CY - ry, CX, CY - ry],
      ['C', CX + KAPPA * rx, CY - ry, CX + rx, CY - KAPPA * ry, CX + rx, CY],
      ['C', CX + rx, CY + KAPPA * ry, CX + KAPPA * rx, CY + ry, CX, CY + ry],
      ['C', CX - KAPPA * rx, CY + ry, CX - rx, CY + KAPPA * ry, CX - rx, CY],
    ];
  }

8本の2次ベジェ曲線で描画

Paths - SVG | MDN から引用。

Q x1 y1, x y
x1,y1 は曲線の始点向けの制御点の座標です。 x,y は終点の座標です。

なぜ8本かという記事はありませんでしたが、おそらく4本の時と同じく誤差の問題だと思います。 Approximating Cubic Bezier Curves in Flash MX の「Flash MX」にある「4 Quadratic curves (4本の2次ベジェ曲線)」の図を見ると少し歪んでるのが目で見ても分かるぐらいなので。

4本の2次ベジェ曲線

実際の円

第4象限 (上)、第4象限 (下)、第3象限 (下)、第3象限 (上)、第2象限 (下)、第2象限 (上)、第1象限 (上)、第1象限 (下) 部分の8本の曲線に分けて描画します。 2本の円弧で描画と同じく横の半径が 40、縦の半径が 30 の楕円を描きます (rx=40, ry=30)。

第4象限 (上)

※見やすさのために第2小数点までで切り捨ててます。
M50,10 Q50,22.42 38.28,31.21

第4象限 (下)

M38.28,31.21 Q26.56,40 10,40

第3象限 (下)

M40,40 Q33.43,40 21.71,31.21

第3象限 (上)

M21.71,31.21 Q10,22.42 10,10

第2象限 (下)

M10,40 Q9.99,27.57 21.71,18.78

第2象限 (上)

M21.71,18.78 Q33.43,10 49.99,10

第1象限 (上)

M10,10 Q26.56,9.99 38.28,18.78

第1象限 (下)

M38.28,18.78 Q50,27.57 50,39.99

実際のコードが生成するパスは以下のようになります。

M (CX + rx) CY
Q (controlX(theta) + CX) (controlY(theta) + CY) (anchorX(theta) + CX) (anchorY(theta) + CY)
Q (上と同じ引数のものが7個続く)
theta はそれぞれの角度 (ラジアン) です。例えば第1象限 (下) だったら 1/4 * π、第1象限 (上)だったら 1/2 * π といった値です。 controlX, controlY, anchorX, anchorY はそれぞれ theta を渡すと制御点と終点の座標を返します。

実際のコードは以下の通りです (該当の箇所)。

  /**
    * http://www.fumiononaka.com/TechNotes/Flash/FN0506002.html
    */
  drawCircle(param) {
    const rx = param.rx;
    const ry = param.figure === 'circle' ? rx : param.ry;
    const SEGMENTS = 8;
    const ANGLE = 2 * Math.PI / SEGMENTS;
    const anchorX = theta => rx * Math.cos(theta);
    const anchorY = theta => ry * Math.sin(theta);
    const controlX = theta => anchorX(theta) + rx * Math.tan(ANGLE / 2) * Math.cos(theta - Math.PI / 2);
    const controlY = theta => anchorY(theta) + ry * Math.tan(ANGLE / 2) * Math.sin(theta - Math.PI / 2);
    return [
      ['M', CX + rx, CY],
      ... this.range(1, SEGMENTS).map(index => {
        const theta = index * ANGLE;
        return ['Q', controlX(theta) + CX, controlY(theta) + CY, anchorX(theta) + CX, anchorY(theta) + CY];
      })
    ];
  }

  range(from, to) {
    const d = to - from;
    return [...Array(d + 1).keys()].map(n => n + from);
  }

参考記事