SVGでベン図を作成

# はじめに

本記事は[Progaku Advent Calendar 2023](https://qiita.com/advent-calendar/2023/progaku)2日目の記事です。

集合の共通部分をビジュアライズするのにベン図という表現方法があります。
ではこれをフロントで実装しようと思ったとき、私の中では2つのアプローチが考えられます。

1. [d3.js](https://d3js.org/)のプラグインである[venn.js](https://github.com/benfred/venn.js/)を使用
1. [matplotlib-venn](https://pypi.org/project/matplotlib-venn/)を用いて裏側でベン図イメージを生成し、フロントで表示

表示するだけなら上記2つを使うだけなのですが、各領域に対してイベントをつけようと思うとどちらも途端に面倒くさくなります。
私があまり分かっていないのもありますし、もしかしたらベン図を作って各領域にイベントを登録できるライブラリやフレームワークがこの世には存在するのかもしれません。が、私は知りません。

ではどうすればいいのでしょうか？
そう、自分で作ればいいんです。

# つくるもの

共通部分のパーセンテージ1~99%に応じて、形を変化させるベン図をSVGで作成します。

サンプルはこちら:

https://stackblitz-starters-jmcspc.stackblitz.io

クリックするとコンソールにどこをクリックしたか出しています。

リアクティブに作成していきたいので[Next.js](https://nextjs.org/)を用います。
Reactだけでやろうと思ったら、2023/12現在の最新ドキュメントにはNext.jsかRemixかGatsbyかを合わせて使う案内しか書かれていなかったので、この中では一番慣れているNext.jsを用いました。
素のTypeScriptでやる選択肢もありましたが、ReactやNext.jsのタグを付けた方が記事を見てもらいやすいので使う選択をしました。

# 実装

ヘッダーも入っちゃっていますが気にしないでください。

算数力(ぢから)不足で計算するのがキツくて、何とか楽にするために2つの円の半径がどちらも100前提で書いています。

そしてパーセンテージが0以下100以上は対応していません。
inputのmax, minで入力できないよう制御しています。

```tsx
import Head from 'next/head'
import styles from '@/styles/App.module.css'
import React, {useEffect, useState} from 'react';

function pythagoreanTheorem(radius: number, percent: number): number {
  const centerHeight = radius - percent;
  return Math.sqrt(radius * radius - centerHeight * centerHeight);
}

export default function App() {
  const RADIUS = 100;
  const WIDTH = 400;
  const HEIGHT = 210;
  const CENTER_X = WIDTH / 2;
  const CENTER_Y = 105;
  const [percent, setPercent] = useState(50);
  const onPercentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPercent(e.target.valueAsNumber);
  };

  const [centerHeight, setCenterHeight] = useState(pythagoreanTheorem(RADIUS, percent));
  useEffect(() => {
    setCenterHeight(pythagoreanTheorem(RADIUS, percent));
  }, [percent]);

  const onClickLeft = () => {console.log('on click left')};
  const onClickRight = () => {console.log('on click right')};
  const onClickCenter = () => {console.log('on click center')};

  return (
    <>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main className={`${styles.main}`}>
        <input
          type="number"
          value={percent}
          max={99}
          min={1}
          onChange={onPercentChange}
        />
        <svg width={WIDTH} height={HEIGHT}>
          <path
            d={`
              M${CENTER_X},${CENTER_Y-centerHeight}
              A${RADIUS},${RADIUS} 0 1 0 ${CENTER_X},${CENTER_Y+centerHeight}
            `}
            fill="white"
            stroke="black"
            onClick={onClickLeft}
          />
          <path
            d={`
              M${CENTER_X},${CENTER_Y-centerHeight}
              A${RADIUS},${RADIUS} 0 1 1 ${CENTER_X},${CENTER_Y+centerHeight}
            `}
            fill="white"
            stroke="black"
            onClick={onClickRight}
          />
          <g onClick={onClickCenter}>
            <path
              d={`
                M${CENTER_X},${CENTER_Y-centerHeight}
                A${RADIUS},${RADIUS} 0 0 0 ${CENTER_X},${CENTER_Y+centerHeight}
              `}
              fill="red"
            />
            <path
              d={`
                M${CENTER_X},${CENTER_Y-centerHeight}
                A${RADIUS},${RADIUS} 0 0 1 ${CENTER_X},${CENTER_Y+centerHeight}
              `}
              fill="red"
            />
          </g>
        </svg>
      </main>
    </>
  )
}
```

# 解説

SVGの全体的な説明については[MDN](https://developer.mozilla.org/ja/docs/Web/SVG)に詳しく書かれていますので、ベン図に関係ない部分はこちらから学んでください。

## `<svg width={WIDTH} height={HEIGHT}></svg>`

そのまんま、横幅縦幅です。
2円の半径が100ずつで、ピッタリ並べる(重複度0%)のと横幅が400になります。
ピッタリだと輪郭 ( [stroke](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/stroke) ) が途切れてしまいますが、今回は0％は指定できないので400としています。
縦幅はピッタリ200だと輪郭がはみ出るので、少し多めに取って210としています。

## `<path />`

これで円弧を描画しています。
[MDN path](https://developer.mozilla.org/ja/docs/Web/SVG/Element/path)にある通り、pathのd属性に色々書いて形を作ります。

サンプル通りに書けばハート形が出来上がります。

```html
<svg width="100" height="100">
    <path
        d="M 10,30
            A 20,20 0,0,1 50,30
            A 20,20 0,0,1 90,30
            Q 90,60 50,90
            Q 10,60 10,30z" />
</svg>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/ef8cae52-ed63-f9e2-1fec-7c72023cf5cf.png)

無限の描画力を持ちそうなpathならばベン図も描画できそうです。

## ベン図描画戦略

今回はベン図を以下の図のように3つに分離します。(※図はパワーポイントで作成しているので適当です)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/16398419-d9e6-2b5a-22c6-3b79ec49e138.png)

それぞれを作成して組み合わせたらそれぞれの領域にイベントを持たせられるベン図になりそうです。
ではどうやってSVGにするのでしょうか？

## 円弧

[d#楕円円弧曲線](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/d#%E6%A5%95%E5%86%86%E5%86%86%E5%BC%A7%E6%9B%B2%E7%B7%9A)にある通り、円弧を二つ書けば円になります。
例は楕円ですが、縦半径と横半径が同じ楕円が円になります。

例を半径同じに書き換えて、始点Mを調節して動かしてみます。

```html
<svg width="200" height="200">
    <path
        fill="none"
        stroke="red"
        d="
            M 100,75
            A 50,50 0 1 0 100,125
        " />
    <path
        fill="none"
        stroke="lime"
        d="
            M 100,75
            A 50,50 0 1 1 100,125
        " />
    <path
        fill="none"
        stroke="purple"
        d="
            M 100,75
            A 50,50 0 0 1 100,125
        " />
    
    <path
        fill="none"
        stroke="pink"
        d="
            M 100,75
            A 50,50 0 0 0 100,125
        " />
</svg>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/93bd2a3d-c375-89f1-7d49-8784a7d1e15f.png)

出来てそうです！

### `M`

[MoveTo パスコマンド](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/d#moveto_%E3%83%91%E3%82%B9%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89)にある通り、始点を(x,y)で示します。
とりあえず真ん中に重なりを置きたいので $x = width/2 = 200$ , 円の中心が $y=100$ に来ているものと考え、
大体半径の半分くらいのところで交わっていてほしいので $y = 100 - (半径(=50)/2) = 75$ としました。
円弧の終点は `A` コマンドで指定するのですが、小円弧にするか大円弧にするかも `A` コマンドでの指定となるため、4種類の線はいずれも同じ始点を持つことになります。

### `A`

[d#楕円円弧曲線](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/d#%E6%A5%95%E5%86%86%E5%86%86%E5%BC%A7%E6%9B%B2%E7%B7%9A)にある通り、

`A 半径x,半径y 角度 大円弧フラグ 掃引フラグ 終点x,終点y`

と指定します。
半径はx, yどちらも50で、角度は特につけないので0。
円は上下の対称性があるため、 $x$ はそのまま100, $y = 100 + (半径(=50)/2) = 125$ より終点を125としています。

掃引フラグは

> 時計回りの円弧 (1) を描くか、反時計周りの円弧 (0) を描くか

なのですが、大円弧フラグはなんなのでしょうか？

チュートリアルに円弧について記載があるので見てみます。

- [Tutorial/Paths#円弧](https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Paths#%E5%86%86%E5%BC%A7)

> これは単純に円弧が 180 度より大きいか小さいかを決定します

とある通り、小円弧にするか大円弧にするかを決めています。
素晴らしい図があるのでそのまま使わせてもらいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/8764ab89-5862-486e-37d9-2dd4183f8e20.png)

`large arc sweep flag` が大円弧フラグを、 `sweep flag` が掃引フラグを示しています。

## グループ化

クリックイベントを付けたいのでまとめます。
[g tag](https://developer.mozilla.org/ja/docs/Web/SVG/Element/g)を用います。

ここからは定義の順番が重要で、中の共通部分を後に定義してあげないと後ろに隠れてしまいます。
試しにやってみます。

```html
<svg width="200" height="200">
    <g>
        <path
            fill="black"
            d="
                M 100,75
                A 50,50 0 0 1 100,125
            " />
        
        <path
            fill="black"
            d="
                M 100,75
                A 50,50 0 0 0 100,125
            " />
    </g>
    <path
        fill="red"
        d="
            M 100,75
            A 50,50 0 1 0 100,125
        " />
    <path
        fill="lime"
        d="
            M 100,75
            A 50,50 0 1 1 100,125
        " />
</svg>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/01abfb98-de67-192e-3cd1-29b215cc7130.png)

下に定義されたものが上位レイヤーになります。

```html
<svg width="200" height="200">
    <path
        fill="red"
        d="
            M 100,75
            A 50,50 0 1 0 100,125
        " />
    <path
        fill="lime"
        d="
            M 100,75
            A 50,50 0 1 1 100,125
        " />
    <g>
        <path
            fill="black"
            d="
                M 100,75
                A 50,50 0 0 1 100,125
            " />
        
        <path
            fill="black"
            d="
                M 100,75
                A 50,50 0 0 0 100,125
            " />
    </g>
</svg>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/4a231514-bae2-c598-f6e7-8e80380a53df.png)

また、Next.jsでクリックイベントを付けたときに知ったのですが、 `fill=none` だと空白部分にクリックイベントがつかないので色付けするなどして空白を埋めてあげた方がいいです。背景色と同じでも大丈夫です。 (例えば背景色が白なら、 `fill="white"` )

## パーセンテージに応じて共通部分の大きさを変える

せっかく半径を100で行っているので、下記図の青線部分を $100-現在のパーセンテージ$ としてあげればよさそうと考えました。0%なら半径がそのまま残るし、100%になったら2円の中心が重なって表示される円が1つに見えるようになるためです。
動かしてみて違いそうだったらまた別の方法を考えようとしていましたが、この方法でそれっぽくなりました。
面積的にも本当にパーセンテージ通りになっているのかは未証明ですし、証明する方法が思いつきません。
円の半径が異なる場合はこの方法では円の中心が重なる前に片方の円がすっぽり内包されてしまうので、もし正しかったとしても今回限りのやり方でしょうね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/e15c5621-2a97-8e0e-3426-f3a3fefd6edd.png)

しかし青色部分はsvgで指定できません。
指定できるのは円弧を描くための始点と終点です。
x座標はsvgの横幅の中心に持っていきたい。となるとy座標を求めればいい・・・
ならば、赤線部分を求めて、円の中心y座標 (=100) に足し引きすれば始点 $y_{top}$ 及び終点 $y_{bottom}$ を割り出せますね。

```math
\displaylines{
y_{top} = 100 - 赤線の長さ \\
y_{bottom} = 100 + 赤線の長さ
}
```

でも赤線部分ってどう求めればいいのでしょうか？

- [円と円の交点](https://www.nekonecode.com/math-lab/pages/collision2/circle-and-circle-pos/)

ここに答えがありました。
三平方の定理です。

「Q. 三平方の定理ってなんの役に立つんだよ」の答えがここにありました。
「A. ベン図を書くときに使う」です。

斜辺は半径なので100, 底辺は100から%を引いた値になります。
ここから赤線の長さをhとして、

```math
h = \sqrt{(半径)^2+(半径-\%)^2}
```

これをTypeScriptに落とし込んだのが下記関数になります。

```ts
function pythagoreanTheorem(radius: number, percent: number): number {
  const centerHeight = radius - percent;
  return Math.sqrt(radius * radius - centerHeight * centerHeight);
}
```

あとはここまでを実装に落とし込んで、ちょろっとクリックイベントを生やしてあげれば、 `#実装` の章にある通りになります。

### `const CENTER_Y = 105;`

これまで分かりやすさ
優先で、円の中心のy座標を100と散々述べてきましたが、svg領域のheightが210なので、100だと上のstrokeが途切れてしまいます。
なので105にしています。

# おわりに

ここまで読んでいただきありがとうございました。
数学に対する理解度がもろに問われ、大変苦しかったですがその分実装できたときの喜びは大きかったです。
最初は3つまでなら何とかなるかもな～(４つだと楕円の重なりが複雑だから厳しそう)と考えていました。
しかし流石に実装してわかりました。3つでもバカむずい！

3つ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/aa645a0d-b58b-4984-55c4-6b65424f8cb1.png)

4つ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/f3ff9fae-0e15-b3c9-2d66-7b1fa83aabcb.png)

なので今回は2つの円のベン図のみとします。
以上、[Progaku Advent Calendar 2023](https://qiita.com/advent-calendar/2023/progaku)2日目でした。