ReactとSVGで、ドーナツグラフを描く方法

​​React​CSS,Playwright,SVG,TypeScript

ドーナツグラフの扇形は、SVG の <path> 1つで描けます。外側の円弧で時計回りに進み、内側の円弧で戻って閉じれば、輪を切り取った形になります。気を付けるのは2か所だけです。半周を超える扇形では large-arc-flag を 1 にすること、1項目だけで100%になるときは弧を半周ずつ2本に分けることです。

ライブラリを使わずに、DonutChart というコンポーネントを作りました。扇形か凡例をクリックすると、その扇形が外へ少しずれ、中心に割合が出ます。データは架空の数字です。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、形の大きさや表示を確かめました。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。負の値を含むデータも扱っていません。

飲み物ごとの注文数のドーナツグラフ。コーヒーの扇形をクリックすると、その扇形が外へ少しずれ、ほかの扇形が薄くなり、中心の表示が「合計 100杯」から「コーヒー 56.0%」に変わる。続けて緑茶の扇形をクリックすると「緑茶 13.0%」、凡例の「ココア」を押すと「ココア 9.0%」になり、もう一度ココアを押すと選択が外れて「合計 100杯」に戻る。表示が変わった直後だけ、中心の文字と凡例の太字がピンクになる
扇形と凡例のクリックで、選ぶ項目を変えた様子(上の帯、灰色の丸のカーソル、ピンクの輪、変わった文字のピンクは GIF 用に重ねたもの。GIF ではグラフを大きくしている)

値を、12時から時計回りの角度に直す

まず、それぞれの値を合計で割って割合にし、それを足し合わせながら角度に直します。1周は 2π ラジアンです。12時の位置を 0 として時計回りに並べるので、最初の項目は 0 から始まり、最後の項目は 2π で終わります。

toArcs に、コーヒー 56、紅茶 22、緑茶 13、ココア 9(合計 100)を渡すと、角度は次のようになりました(ラジアンを度に直して表示しています)。

項目 割合 始まり 終わり
コーヒー 0.56 0° 201.6°
紅茶 0.22 201.6° 280.8°
緑茶 0.13 280.8° 327.6°
ココア 0.09 327.6° 360°

角度を円周上の点に直すときは、x に sin、y に cos を使います。数学の教科書の書き方(3時の位置から反時計回り)とは始まりと向きが違うためです。さらに、MDN の SVG の座標の説明にあるとおり、SVG の y は下に向かって増えます。そこで、中心の y から r × cos を引くと、角度 0 が真上の点になります。

輪を切り取った形を、path の d 属性で書く

角度の計算と、扇形の d 属性を作る関数を、1つのファイルにまとめました。

ts
export type Slice = { label: string; value: number; color: string };
export type Arc = Slice & { ratio: number; start: number; end: number };

// 値を、12時の位置から時計回りに並べた角度(ラジアン)に変える
export function toArcs(data: Slice[]): Arc[] {
  const total = data.reduce((sum, d) => sum + d.value, 0);
  let acc = 0;
  return data.map((d) => {
    const ratio = total > 0 ? d.value / total : 0;
    const start = acc * 2 * Math.PI;
    acc += ratio;
    return { ...d, ratio, start, end: acc * 2 * Math.PI };
  });
}

// 12時の位置を 0 として、時計回りに a だけ回った円周上の点
function point(cx: number, cy: number, r: number, a: number): string {
  const x = cx + r * Math.sin(a);
  const y = cy - r * Math.cos(a); // SVG の y は下向きに増えるので引く
  return `${x.toFixed(2)} ${y.toFixed(2)}`;
}

// 外側の半径 r、内側の半径 ri の輪を、start から end まで切り取った形の d 属性
export function arcPath(cx: number, cy: number, r: number, ri: number, start: number, end: number): string {
  if (end - start >= 2 * Math.PI - 1e-6) {
    // 1周ちょうどだと始点と終点が同じ点になり、弧が描かれない。半周ずつ2本に分ける
    const half = start + Math.PI;
    return [
      `M ${point(cx, cy, r, start)}`,
      `A ${r} ${r} 0 1 1 ${point(cx, cy, r, half)}`,
      `A ${r} ${r} 0 1 1 ${point(cx, cy, r, start)}`,
      'Z',
      `M ${point(cx, cy, ri, start)}`,
      `A ${ri} ${ri} 0 1 0 ${point(cx, cy, ri, half)}`,
      `A ${ri} ${ri} 0 1 0 ${point(cx, cy, ri, start)}`,
      'Z',
    ].join(' ');
  }
  const large = end - start > Math.PI ? 1 : 0; // 半周より長い弧なら 1
  return [
    `M ${point(cx, cy, r, start)}`,
    `A ${r} ${r} 0 ${large} 1 ${point(cx, cy, r, end)}`, // 外側の弧(時計回り)
    `L ${point(cx, cy, ri, end)}`,
    `A ${ri} ${ri} 0 ${large} 0 ${point(cx, cy, ri, start)}`, // 内側の弧(反時計回りで戻る)
    'Z',
  ].join(' ');
}

扇形1つの d 属性は、次の5つの命令でできています。

  1. M:外側の円の、始まりの角度の点に移る
  2. A:外側の円に沿って、終わりの角度の点まで時計回りに進む
  3. L:内側の円の、終わりの角度の点まで直線で進む
  4. A:内側の円に沿って、始まりの角度の点まで反時計回りに戻る
  5. Z:最初の点まで閉じる

MDN の d 属性の説明によると、円弧の命令 A は A rx ry 回転 large-arc-flag sweep-flag x y の形で書きます。sweep-flag が 1 なら時計回り、0 なら反時計回りです。外側の弧は時計回りに進むので 1、内側の弧は戻るので 0 にしています。

外側の半径を 104、内側の半径を 70、中心を (120, 120) にして、紅茶の扇形を作ると、次の d 属性になりました。

text
M 81.72 216.70 A 104 104 0 0 1 17.84 100.51 L 51.24 106.88 A 70 70 0 0 0 94.23 185.08 Z

座標は toFixed(2) で小数2桁にしています。丸めないと、この点の x は 81.71504652079355 のような長い数字になり、そのまま属性に入ります。

半周を超える扇形は、large-arc-flag を 1 にする

円弧の始まりと終わりの点、半径、向きが決まっても、通る道はまだ2通りあります。短い方の弧と、長い方の弧です。どちらを通るかを決めるのが large-arc-flag で、前の節で触れた MDN の d 属性の説明のとおり、1 なら大きい弧、0 なら小さい弧になります。

arcPath では、扇形の角度が半周(π)より大きいときだけ 1 にしています。この条件を付けずに、いつも 0 にした場合と比べました。

上下2つのドーナツグラフ。上は large-arc-flag をいつも 0 にした場合で、56% のコーヒーの扇形が、輪の右半分ほどの細い形になり、円の外形もゆがんでいる。下は半周を超える弧だけ 1 にした場合で、コーヒーの扇形が12時から7時の少し手前まで、正しく輪に沿っている
large-arc-flag を、いつも 0 にした場合(上)と、半周を超えるときだけ 1 にした場合(下)

56% のコーヒーの扇形(201.6°)は、フラグが 0 だと、時計回りのまま短い方の弧を通ろうとします。元の円の上では、時計回りの弧は 201.6° あって短い方になりません。そこで、2点を結ぶ線で中心を折り返した、半径 104 の別の円が使われます。その円の 158.4° 分の弧で描かれるので、上の画像のように右に寄った細い形になりました。getBBox() で測った扇形の外枠は、フラグを 1 にすると幅 142.3・高さ 208、0 にすると幅 104・高さ 200.7 でした。ほかの3つの扇形は半周より小さいので、フラグが 0 のままで正しく描かれています。

1項目だけで100%になるときは、弧を2本に分ける

データが1項目だけのときや、ほかの値がすべて 0 のときは、1つの扇形が始まりから1周して、同じ点に戻ってきます。このとき、弧の始まりと終わりの点が同じになり、円弧は描かれません。

試しに、1周を1本の弧で書いた次の d 属性を描くと、getBBox() は幅 0・高さ 34 でした。測れたのは、外側の円から内側の円へ引いた直線(L)の分だけです。面積のある形がないので、何も塗られません。

text
M 120.00 16.00 A 104 104 0 1 1 120.00 16.00 L 120.00 50.00 A 70 70 0 1 0 120.00 50.00 Z

MDN のパスのチュートリアルにも、円をまるごと描くのは SVG の円弧が苦手とする形で、半円ずつの弧に分ければ描ける、と書かれています。arcPath も、1周のときは外側と内側の円を、それぞれ半周ずつ2本の弧で描いています。こうすると getBBox() は幅 208・高さ 208 になりました。

内側の円は、外側とは逆に反時計回りで描いています。MDN の fill-rule の説明にあるとおり、標準の nonzero では、点から伸ばした半直線と交わる線を、向きによって +1 と −1 で数え、合計が 0 なら外側とみなします。外側を時計回り、内側を反時計回りにすると、穴の中の点では合計が 0 になり、塗られません。document.elementFromPoint で中心の点 (120, 120) を調べると、返ってきたのは <path> ではなく <svg> でした。穴の部分は塗られておらず、下の <svg> に当たったということです。

項目を選べる DonutChart のコード

扇形、中心の文字、凡例をまとめたコンポーネントです。

tsx
import { useId, useState } from 'react';
import { arcPath, toArcs, type Slice } from './arc';

type Props = {
  data: Slice[];
  title: string; // 読み上げ用の名前(<title> に入る)
  unit?: string;
};

const SIZE = 240; // viewBox の幅と高さ
const C = SIZE / 2; // 中心の座標
const R = 104; // 外側の半径(選んだ扇形を外へ 6 ずらしても、はみ出さない大きさ)
const RI = 70; // 内側の半径(穴の大きさ)

const percent = (ratio: number) => `${(ratio * 100).toFixed(1)}%`;

export function DonutChart({ data, title, unit = '' }: Props) {
  const titleId = useId();
  const descId = useId();
  const [active, setActive] = useState<number | null>(null);
  const arcs = toArcs(data);
  const total = data.reduce((sum, d) => sum + d.value, 0);
  const toggle = (i: number) => setActive((cur) => (cur === i ? null : i));
  const sel = active === null ? null : arcs[active];

  return (
    <figure className="donut">
      <svg
        viewBox={`0 0 ${SIZE} ${SIZE}`}
        role="img"
        aria-labelledby={titleId}
        aria-describedby={descId}
        style={{ display: 'block', width: '100%', maxWidth: SIZE, height: 'auto' }}
      >
        <title id={titleId}>{title}</title>
        <desc id={descId}>
          {arcs.map((a) => `${a.label} ${a.value}${unit}(${percent(a.ratio)})`).join('、')}
        </desc>
        {arcs.map((a, i) => {
          const mid = (a.start + a.end) / 2;
          // 選んだ扇形だけ、中心から外へ 6 ずらす
          const dx = i === active ? 6 * Math.sin(mid) : 0;
          const dy = i === active ? -6 * Math.cos(mid) : 0;
          return (
            <path
              key={a.label}
              className="slice"
              d={arcPath(C, C, R, RI, a.start, a.end)}
              fill={a.color}
              stroke="#fff"
              strokeWidth={2}
              opacity={active === null || i === active ? 1 : 0.45}
              style={{ transform: `translate(${dx}px, ${dy}px)` }}
              onClick={() => toggle(i)}
            />
          );
        })}
        <text x={C} y={C - 8} textAnchor="middle" fontSize={14} fill="#5b6870">
          {sel ? sel.label : '合計'}
        </text>
        <text x={C} y={C + 18} textAnchor="middle" fontSize={22} fontWeight="bold" fill="#1f2a30">
          {sel ? percent(sel.ratio) : `${total}${unit}`}
        </text>
      </svg>
      <figcaption>
        <ul className="legend">
          {arcs.map((a, i) => (
            <li key={a.label}>
              <button type="button" aria-pressed={i === active} onClick={() => toggle(i)}>
                <span className="swatch" style={{ background: a.color }} aria-hidden="true" />
                {a.label} {a.value}
                {unit}({percent(a.ratio)})
              </button>
            </li>
          ))}
        </ul>
      </figcaption>
    </figure>
  );
}

使う側では、項目ごとに名前、値、色を渡します。

tsx
import { DonutChart } from './DonutChart';
import type { Slice } from './arc';

// 例のデータ(架空の数字)
const drinks: Slice[] = [
  { label: 'コーヒー', value: 56, color: '#00c8e8' },
  { label: '紅茶', value: 22, color: '#067a98' },
  { label: '緑茶', value: 13, color: '#7fdcec' },
  { label: 'ココア', value: 9, color: '#9aa5ab' },
];

export function App() {
  return <DonutChart data={drinks} title="飲み物ごとの注文数" unit="杯" />;
}

選んだ項目の番号を active に持ち、同じ項目をもう一度押すと null に戻して選択を外します。選んだ扇形は、扇形の真ん中の角度の向きに 6 だけ外へずらし、ほかの扇形は opacity を 0.45 にして薄くします。外へずらしてもグラフの外にはみ出さないよう、外側の半径は viewBox の半分(120)より小さい 104 にしました。検証でコーヒーを選ぶと、コーヒーの扇形の transform は matrix(1, 0, 0, 1, 5.89372, 1.12429)、つまり右へ約 5.9、下へ約 1.1 動きました。

中心の文字は、何も選んでいないときは「合計」と合計の値、選んだときは項目名と割合です。割合は小数1桁で表示しています。3つの値が同じだと、どれも「33.3%」と表示されるので、表示上の合計は 99.9% になります。

扇形の <path> は、クリックはできても、Tab キーでは選べません。検証でも、ページを読み込んだ直後に Tab キーを押すと、フォーカスは扇形を飛ばして、凡例の「コーヒー」のボタンに移りました。そこで、凡例を <button> にして、aria-pressed で選んでいるかどうかを伝えています。フォーカスのあるボタンで Enter キーを押すと、その項目を選べました。<button> なら、Enter キーで押す処理をブラウザが受け持つので、自分で書く必要がありません。

MDN の title の説明とdesc の説明にあるとおり、<title> と <desc> は、グラフとしては描かれない、読み上げ用の名前と説明です。<title> は、マウスを重ねるとツールチップで出すブラウザが多いとされています。id は useId で作っています。Chromium のアクセシビリティツリーでは、このグラフは名前が「飲み物ごとの注文数」、説明が「コーヒー 56杯(56.0%)、紅茶 22杯(22.0%)、緑茶 13杯(13.0%)、ココア 9杯(9.0%)」の画像(image)になっていました。<title> と <desc> や、viewBox で画面の幅に合わせる方法は、このブログの「Reactで、ライブラリを使わずにSVGで棒グラフを描く方法」でも扱っています。

扇形の動きは、CSS の transition で付けました。動きを減らす設定(prefers-reduced-motion)が有効なときは、transition: none にしています。設定なしでは、コーヒーを押して約50ms後の扇形のずれは右へ約 1.3 で、その約400ms後(押してから約450ms後)には、最後の位置の約 5.9 になりました。Playwright で動きを減らす設定を有効にすると、紅茶を押して約50ms後には、もう最後の位置(左へ約 5.3、下へ約 2.9)にありました。

css
.slice { cursor: pointer; transition: transform 0.2s ease, opacity 0.2s ease; }
@media (prefers-reduced-motion: reduce) { .slice { transition: none; } }

色見本の大きさと、選んでいる凡例の太字(aria-pressed を手がかりにする)は、次の CSS で付けています。

css
.legend { list-style: none; padding: 0; margin: 12px 0 0; }
.legend button { display: flex; align-items: center; gap: 8px; border: 0; background: none; padding: 4px; font: inherit; cursor: pointer; }
.legend button[aria-pressed="true"] { font-weight: bold; }
.swatch { width: 12px; height: 12px; border-radius: 2px; }

扇形の間の白いすき間は、stroke="#fff" と strokeWidth={2} で、扇形の輪郭に白い線を引いて作っています。

circle の stroke-dasharray で描く書き方

ドーナツグラフは、太い線で描いた <circle> を破線にして描くこともできます。pathLength と stroke-dasharray で円弧を描く仕組みは、このブログの「Reactで、ドラッグで回して値を変えるダイヤルを作る方法」でも扱っているので、ここでは要点と、path の書き方との違いだけを確かめます。円周の長さを pathLength="100" で 100 とみなし、stroke-dasharray に「線の長さ 割合、すき間 100 − 割合」を、stroke-dashoffset に「それまでの割合の合計」をマイナスにして渡します。コーヒーの1本目は次のようになります。

html
<circle cx="120" cy="120" r="87" fill="none" stroke="#00c8e8" stroke-width="34" pathLength="100" stroke-dasharray="56 44" stroke-dashoffset="0" transform="rotate(-90 120 120)"/>

半径の 87 は外側(104)と内側(70)の真ん中、線の太さの 34 はその差です。MDN の pathLength の説明によると、pathLength を付けると、stroke-dasharray などの長さの計算が、その値を全体の長さとして行われます。そのため、割合の数字をそのまま破線の長さに使えます。

最後の rotate(-90 120 120) は、線の始まりを12時の位置にそろえるためのものです。SVG 2 の仕様では、<circle> の線は3時の位置から時計回りに進むと決まっています。両方の書き方で描いて画素を比べると、回転を付けた場合はほぼ一致し(57,600 画素中 47 画素が違う)、付けない場合は 12,767 画素が違いました。

この書き方では、1項目だけで100%のときも、stroke-dasharray="100 0" を渡すだけで輪が描けました。12時・3時・6時・9時の輪の上の点は、どれも水色に塗られていました。一方で、扇形の形は線の太さと破線の長さで決まるので、選んだ扇形だけを外へずらしたり、扇形の輪郭に白い線を引いて区切ったりするには、別の工夫が必要です。この記事のコンポーネントで <path> を使ったのは、扇形1つが1つの塗りの形になり、その形をそのまま動かしたり、線で縁取ったりできるからです。

​Posted by 86