JavaScript

Apache ECharts 円グラフ入門|円・ドーナツの実装からボタン切り替えまで

みなと

この記事は、Apache EChartsを使った円グラフとドーナツグラフの実装解説です。ECharts自体の概要やセットアップ方法は、過去の折れ線グラフ記事でまとめています。EChartsが初めての方は、先にそちらをお読みください。

あわせて読みたい
Apache ECharts 折れ線グラフ入門|最小サンプルから実務レベルに仕上げる手順
Apache ECharts 折れ線グラフ入門|最小サンプルから実務レベルに仕上げる手順
あわせて読みたい
Apache ECharts 棒グラフ入門|縦棒・横棒の実装からボタン切り替えまで
Apache ECharts 棒グラフ入門|縦棒・横棒の実装からボタン切り替えまで

Apache ECharts 公式サイト

この記事では、円グラフとドーナツグラフの実装を中心に、デザイン調整・レスポンシブ対応・ボタン切り替え、そしてドーナツの中央テキスト表示まで一通り解説します。

完成デモを触ってみよう

まずは完成デモを見て、最終的にどんな形を目指すか確認してみましょう。ボタンを押すと、ページ別の流入経路アクセス数の円グラフ(※数値はサンプル)を表示・切り替えできます。

ボタンを押すと
グラフが表示されます

PC幅で見ると、円グラフがコンテナの中央に置かれ、その周りに「カテゴリ名と割合」のラベルが引き出し線付きで外側に表示されます。スライスにマウスを当てるとツールチップが出て、シャドウがついてどこを指しているかひと目で分かるようになっています。

スマホ幅で見ると、凡例が下に縦並びで並びます。凡例の中には「カテゴリ名:値 PV(割合)」とフルテキストで書き出されるので、円の外側のラベルなしでも各カテゴリの情報が読めるようになっています。タップ操作前提なので、ツールチップとホバー時のシャドウは無効化しています。

コード全体(HTML / CSS / JavaScript)

ここからは実際にコードを見ていきます。HTML・CSSはコードとポイントをあわせて解説します。JavaScriptは全体を掲載してから、次のセクションで詳しく解説します。

HTML

<!-- 切り替えボタンのエリア -->
<div class="chart-switcher">
  <button id="serviceChartBtn" class="chart-tab" type="button">サービス紹介ページ</button>
  <button id="blogChartBtn" class="chart-tab" type="button">ブログ記事ページ</button>
</div>

<!-- グラフ表示エリア -->
<div id="echartsContainer" class="echarts-container">
  <!-- グラフが描画される前に表示するプレースホルダー -->
  <div class="chart-placeholder">
    <p class="chart-placeholder-text">ボタンを押すと<br>グラフが表示されます</p>
  </div>
</div>

HTMLはシンプルな2つのブロックで構成されています。

切り替えボタンのエリアには、id を付けた2つの button を並べています。この id をJavaScript側で取得して、クリックイベントに紐づけます。

グラフ表示エリアは id=”echartsContainer” の div で、EChartsはこの要素をコンテナとしてグラフを描画します。初期状態ではプレースホルダー(「ボタンを押すとグラフが表示されます」)を表示しておき、ボタンを押したタイミングでグラフに置き換わります。

スワン
スワン

EChartsのグラフ描画に必須なのは、コンテナとなる div(id=”echartsContainer”)だけです。ボタンやプレースホルダーは今回のデモ用の実装なので、ただグラフを表示したいだけであれば、コンテナのみで問題ありません。

CSS

/* ===============================
   切り替えボタンエリア
   =============================== */
.chart-switcher {
  display: flex;
  justify-content: center;
}

@media (min-width: 768px) {
  .chart-switcher {
    gap: 30px;
  }
}

@media (max-width: 767px) {
  .chart-switcher {
    gap: 15px;
  }
}

/* ボタンの基本スタイル */
.chart-tab {
  display: block;
  position: relative;
  border: none;
  border-radius: 5px;
  padding: 15px 0;
  background: #c11a51;
  color: #fff;
  font: inherit;
  font-weight: 700;
  line-height: 1.4;
  text-align: center;
  cursor: pointer;
  appearance: none;
  transition: background-color 300ms, color 300ms;
}

@media (min-width: 768px) {
  .chart-tab {
    width: 200px;
    font-size: 16px;
  }
}

@media (max-width: 767px) {
  .chart-tab {
    flex: 1;
    width: 100%;
    font-size: 15px;
  }
}

/* 選択中ボタンの下に表示する三角形 */
.chart-tab::before {
  content: "";
  display: block;
  position: absolute;
  left: 50%;
  bottom: -12px;
  width: 15px;
  height: 12px;
  background: #f9e8ed;
  clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
  transform: translateX(-50%);
  opacity: 0;
  transition: opacity 300ms;
}

/* ホバー時(PCのみ) */
@media (min-width: 768px) {
  .chart-tab:hover {
    background: #890631;
  }
}

/* 選択中のボタン */
.chart-tab.is-active {
  background: #f9e8ed;
  color: #c11a51;
  pointer-events: none;
}

/* アクティブ時だけ三角を表示 */
.chart-tab.is-active::before {
  opacity: 1;
}

/* ===============================
   グラフエリア
   =============================== */
@media (min-width: 768px) {
  .echarts-container {
    height: 400px; /* PCのチャート高さ */
    margin-top: 40px;
  }
}

@media (max-width: 767px) {
  .echarts-container {
    height: 380px; /* SPのチャート高さ */
    margin-top: 30px;
  }
}

/* プレースホルダー(グラフ描画前の表示) */
.chart-placeholder {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  border-radius: 5px;
  background-color: rgba(68, 102, 206, 0.09);
  text-align: center;
}

.chart-placeholder-text {
  color: #31345e;
  font-weight: 700;
  line-height: 1.6;
  opacity: 0.5;
}

@media (min-width: 768px) {
  .chart-placeholder-text {
    font-size: 26px;
  }
}

@media (max-width: 767px) {
  .chart-placeholder-text {
    font-size: 20px;
  }
}

CSSも大きく2つのブロックに分かれています。

ボタンエリアは display: flex で横並びにしています。is-active クラスが付いたボタンは背景色と文字色が反転し、::before 擬似要素で下向きの三角形を表示します。pointer-events: none を合わせて指定することで、選択中のボタンを押せない状態にしています。

グラフエリアはEChartsの仕様上、高さをCSSで指定する必要があります。高さを指定しないとグラフが表示されないため、必ず設定しておきましょう。

スワン
スワン

HTMLと同様に、EChartsのグラフ描画に必須なCSSはコンテナの高さ指定だけです。ボタンやプレースホルダーのスタイルは今回のデモ用の実装です。

JavaScript

document.addEventListener('DOMContentLoaded', () => {
  const btnService = document.getElementById('serviceChartBtn');
  const btnBlog = document.getElementById('blogChartBtn');
  const chartDom = document.getElementById('echartsContainer');

  // コンテナが存在しない場合は処理を止める(エラー防止)
  if (!chartDom) return;

  let myChart = null;

  // 流入経路のカテゴリ(両グラフ共通)
  const categories = ['オーガニック検索', 'SNS', '参照元', '直接', 'その他'];

  // 各ページのアクセス数(categoriesと同じ順序で並べる)
  const serviceValues = [1320, 580, 430, 310, 180];
  const blogValues    = [2140, 980, 310, 220, 130];

  // categoriesとvaluesを組み合わせて、円グラフ用の { name, value } 配列を作るヘルパー
  function buildPieData(values) {
    return categories.map((name, i) => ({ name, value: values[i] }));
  }

  const serviceData = buildPieData(serviceValues);
  const blogData    = buildPieData(blogValues);

  // 凡例に「カテゴリ名:値 PV(パーセント)」を表示するためのフォーマッタ生成関数
  function createLegendFormatter(data) {
    const total = data.reduce((sum, item) => sum + item.value, 0);
    return (name) => {
      const item = data.find(d => d.name === name);
      if (!item) return name;
      const percent = ((item.value / total) * 100).toFixed(2);
      return `${name}:${item.value.toLocaleString()} PV(${percent}%)`;
    };
  }

  // 2つのグラフで共通する設定をまとめたベースoption
  const baseOption = {
    // 円グラフの各スライスに使う色(dataの順番に適用される)
    color: ['#c11a51', '#4466ce', '#f9d77a', '#93c98c', '#c9cde0'],
    // グラフ全体の文字スタイル
    textStyle: {
      color: '#333',
      fontFamily: '"Noto Sans JP", sans-serif',
      fontSize: 14,
      fontWeight: 300
    },
    // 凡例の共通設定(表示・非表示や向きはmedia側で切り替え)
    legend: {
      itemGap: 12,
      itemWidth: 16,
      itemHeight: 12,
      selectedMode: false
    },
    // ツールチップ(ホバー時・タップ時の情報表示)
    tooltip: {
      trigger: 'item',
      formatter: (params) =>
        `${params.name}<br>${params.value.toLocaleString()} PV(${params.percent}%)`,
      textStyle: {
        color: '#333',
        fontFamily: '"Noto Sans JP", sans-serif',
        fontSize: 12,
        fontWeight: 300
      },
      padding: 15
    },
    // チャートの幅に応じてレイアウトを切り替え
    media: [
      {
        // PC幅:凡例を非表示、円グラフを中央配置、タイトルは大きめのフォントサイズ
        query: { minWidth: 740 },
        option: {
          title: {
            textStyle: { fontSize: 18 }
          },
          legend: { show: false },
          tooltip: { show: true },
          series: [{
            radius: '70%',
            center: ['50%', '230px'],
            label: {
              show: true,
              formatter: '{b}\n{d}%',
              lineHeight: 19
            }
          }]
        }
      },
      {
        // スマホ幅:凡例を縦並びで下に、ホバー動作は無効化、タイトルは小さめのフォントサイズ
        query: { maxWidth: 739 },
        option: {
          title: {
            textStyle: { fontSize: 16 }
          },
          legend: {
            show: true,
            orient: 'vertical',
            bottom: 0,
            left: 'center'
          },
          // スマホはタップ操作のため、ツールチップも非表示
          tooltip: { show: false },
          series: [{
            radius: '55%',
            center: ['50%', '140px'],
            label: { show: false },
            // スマホはタップ操作のため、ホバー時のハイライト動作を無効化
            emphasis: { disabled: true }
          }]
        }
      }
    ]
  };

  // サービス紹介ページのグラフoption(baseOptionに差分を追加)
  const optionService = {
    ...baseOption,
    title: {
      text: 'サービス紹介ページ 流入経路別アクセス数',
      top: 0,
      left: 'center'
    },
    legend: {
      ...baseOption.legend,
      formatter: createLegendFormatter(serviceData)
    },
    series: [{
      name: '流入経路',
      type: 'pie',
      data: serviceData,
      labelLine: { show: true },
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        }
      }
    }]
  };

  // ブログ記事ページのグラフoption(baseOptionに差分を追加)
  const optionBlog = {
    ...baseOption,
    title: {
      text: 'ブログ記事ページ 流入経路別アクセス数',
      top: 0,
      left: 'center'
    },
    legend: {
      ...baseOption.legend,
      formatter: createLegendFormatter(blogData)
    },
    series: [{
      name: '流入経路',
      type: 'pie',
      data: blogData,
      labelLine: { show: true },
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        }
      }
    }]
  };

  // グラフを描画し、押したボタンをアクティブにする関数
  function showChart(optionToSet, activeBtn) {
    // まだ初期化していない場合のみecharts.init()を呼ぶ(二重初期化防止)
    if (!myChart) {
      myChart = echarts.init(chartDom);
    }
    // 第2引数のtrueで前の設定を残さず新しいoptionに切り替える
    myChart.setOption(optionToSet, true);

    // ボタンのアクティブ状態を切り替える
    [btnService, btnBlog].forEach(btn => btn.classList.remove('is-active'));
    activeBtn.classList.add('is-active');
  }

  btnService.addEventListener('click', () => showChart(optionService, btnService));
  btnBlog.addEventListener('click', () => showChart(optionBlog, btnBlog));

  // 画面サイズ変更時にグラフを再描画(描画済みの場合のみ)
  window.addEventListener('resize', () => {
    if (myChart) myChart.resize();
  });
});

JavaScript のポイント解説

JavaScript の全体構造

このコードは、大きく5つのブロックで構成されています。

ブロック役割
baseOption2つのグラフで共通する設定をまとめたベース
optionService と optionBlogページごとのグラフ設定(baseOption に差分を追加)
serviceData / blogData と createLegendFormatterグラフに渡すデータと、凡例の表示文字列を生成する関数
showChart と addEventListenerグラフを描画してボタンのアクティブ状態を切り替える関数と、ボタンのクリックイベントの紐付け
resize画面サイズ変更時にグラフを再描画する処理

baseOption を土台にして、ページごとの差分(タイトル・凡例フォーマッタ・データ)を乗せた optionService / optionBlog を用意し、ボタンを押したときに showChart で切り替える——これがコード全体の基本的な流れです。

1)baseOption:共通設定をまとめる

const baseOption = {
  color: [ ... ],
  textStyle: { ... },
  legend: { ... },
  tooltip: { ... },
  media: [ ... ]
};

2つのグラフで共通する設定——スライスの色・文字スタイル・凡例・ツールチップ・レスポンシブ——をすべて baseOption にまとめています。タイトルとデータ(series)はページごとに異なるため、baseOption には含めていません。グラフの数が増えても、baseOption を変えれば全体に反映されるので管理が楽になります。

baseOption の中身を項目ごとに見ていきます。

color

color: ['#c11a51', '#4466ce', '#f9d77a', '#93c98c', '#c9cde0']

円グラフのスライスに使う色を、配列で順番に指定します。data 配列の順序に対応して、最初のスライスに #c11a51、次のスライスに #4466ce、と割り当てられます。

textStyle

textStyle: {
  color: '#333',
  fontFamily: '"Noto Sans JP", sans-serif',
  fontSize: 14,
  fontWeight: 300
}

グラフ内の文字(タイトル・凡例・ラベルなど)に共通で効く文字スタイルです。色・フォント・サイズ・ウェイトをサイトのデザインに合わせて指定します。

legend

legend: {
  itemGap: 12,
  itemWidth: 16,
  itemHeight: 12,
  selectedMode: false
}

凡例の共通設定です。itemGap は凡例どうしの間隔、itemWidth / itemHeight は凡例アイコンのサイズです。

selectedMode: false は、凡例クリックによる項目の表示・非表示の切り替えを無効にする設定です。デフォルトでは凡例をクリックすると特定の項目を隠せますが、円グラフでは全項目が見えていてほしいので無効にしています。

凡例の表示・非表示や向きはPC・スマホで切り替えるため media 側で、フォーマッタはページごとに違うため optionService / optionBlog 側で指定しています。

tooltip

tooltip: {
  trigger: 'item',
  formatter: (params) =>
    `${params.name}<br>${params.value.toLocaleString()} PV(${params.percent}%)`,
  textStyle: {
    color: '#333',
    fontFamily: '"Noto Sans JP", sans-serif',
    fontSize: 12,
    fontWeight: 300
  },
  padding: 15
}

ホバー時の情報表示です。

trigger: 'item' は、円グラフのように個別のスライスにカーソルを合わせる形式で表示する設定です。

formatter は表示する内容を整える関数で、カテゴリ名・PV数・割合をまとめて表示しています。value.toLocaleString() で数値を3桁区切り(例:1,320)にし、params.percent で割合(EChartsが自動計算したパーセント値)を表示します。

なお、textStyle はグラフ全体の設定が反映されないケースがあるため、ツールチップ側にも同様に指定しています。

media

media: [
  {
    query: { minWidth: 740 },
    option: {
      title: { textStyle: { fontSize: 18 } },
      legend: { show: false },
      tooltip: { show: true },
      series: [{
        radius: '70%',
        center: ['50%', '230px'],
        label: {
          show: true,
          formatter: '{b}\n{d}%',
          lineHeight: 19
        }
      }]
    }
  },
  {
    query: { maxWidth: 739 },
    option: {
      title: { textStyle: { fontSize: 16 } },
      legend: {
        show: true,
        orient: 'vertical',
        bottom: 0,
        left: 'center'
      },
      tooltip: { show: false },
      series: [{
        radius: '55%',
        center: ['50%', '140px'],
        label: { show: false },
        emphasis: { disabled: true }
      }]
    }
  }
]

media は、チャートの幅に応じてレイアウトを切り替えるための仕組みです。CSSのメディアクエリに近い感覚で使えますが、判定するのは「画面幅」ではなく「チャート(コンテナ)の幅」です。

今回はPC幅・スマホ幅それぞれで以下の調整を加えています。

PC幅では、凡例を非表示(legend: { show: false })にして、円グラフをコンテナの中央に大きく配置します(radius: '70%'center: ['50%', '230px'])。各スライスには label.show: true でラベルを外側に表示します。

label.formatter{b} はカテゴリ名、{d} は割合(パーセント)です。{b}\n{d}% の形式で「カテゴリ名 / 割合」を2行で表示しています。

スマホ幅では、凡例を画面下に縦並びで配置(legend: { orient: 'vertical', bottom: 0, left: 'center' })し、円グラフは上寄せに小さめに配置(radius: '55%'center: ['50%', '140px'])します。円の外側にラベルを出すとはみ出しやすいため、label: { show: false } でラベルを非表示にして、その分の情報を凡例側で見せる設計です。

スマホ幅ではタップ操作前提なので、ツールチップ(tooltip: { show: false })とホバー時のハイライト動作(emphasis: { disabled: true })を無効化しています。

2)optionService と optionBlog:差分だけを上書きする

const optionService = {
  ...baseOption,
  title: { ... },
  legend: { ... },
  series: [ ... ]
};

スプレッド構文(...baseOption)で共通設定を継承して、その上から title・legend・series だけ書き換えるパターンです。サービス紹介ページ用とブログ記事ページ用で、それぞれ違う部分だけ差分として書きます。

optionService の中身を項目ごとに見ていきます。

title

title: {
  text: 'サービス紹介ページ 流入経路別アクセス数',
  top: 0,
  left: 'center'
}

タイトル文字列・上端からの位置・水平方向の配置を指定します。ページごとに異なるのはタイトル文字列だけです(ブログ用の optionBlog では「ブログ記事ページ 流入経路別アクセス数」)。

top: 0 でコンテナの上端に置き、left: 'center' で水平方向に中央揃えにしています。

legend

legend: {
  ...baseOption.legend,
  formatter: createLegendFormatter(serviceData)
}

baseOption.legend(共通設定)を ... で展開してから、ページごとの formatter を足しています。

formatter は凡例の各項目に表示する文字列を組み立てる関数です。

EChartsの凡例は、デフォルトではカテゴリ名のみが表示されます。今回のように「カテゴリ名:PV数(割合%)」まで凡例に出したい場合、EChartsには簡単に書ける仕組みがないため、関数で自前で組み立てる必要があります。

また、サービス紹介ページとブログ記事ページで合計値が違うため、createLegendFormatter にページのデータ(serviceData / blogData)を引数として渡しています。createLegendFormatter の中身は、次のセクションで詳しく見ます。

series

series: [{
  name: '流入経路',
  type: 'pie',
  data: serviceData,
  labelLine: { show: true },
  emphasis: {
    itemStyle: {
      shadowBlur: 10,
      shadowColor: 'rgba(0, 0, 0, 0.3)'
    }
  }
}]

実際に描画する円グラフの設定です。

type: 'pie' で円グラフを描画することを示します。data には、ページごとに用意したデータ(serviceData / blogData)を渡します。データの中身は次のセクションで詳しく見ます。

labelLine: { show: true } は、ラベルとスライスをつなぐ引き出し線を表示する設定です。PC幅でのみ意味を持ち、スマホ幅ではラベル自体を非表示にしているので引き出し線も表示されません。

emphasis.itemStyle は、ホバー時にスライスに加えるシャドウの設定です。shadowBlur で影のぼかし、shadowColor で影の色を指定します。スマホ幅では media 側の emphasis: { disabled: true } で無効化しているので、PC幅でのみシャドウが効きます。

optionBlog も同じ構造で、datablogData に差し替えただけです。

3)serviceData / blogData と createLegendFormatter:データと凡例フォーマッタ

ここでは、データの持ち方と、凡例に渡すフォーマッタ関数の作り方を見ていきます。どちらも optionService / optionBlog の中で使われる「素材」のような位置づけです。

serviceData / blogData

const categories = ['オーガニック検索', 'SNS', '参照元', '直接', 'その他'];
const serviceValues = [1320, 580, 430, 310, 180];
const blogValues    = [2140, 980, 310, 220, 130];

function buildPieData(values) {
  return categories.map((name, i) => ({ name, value: values[i] }));
}

const serviceData = buildPieData(serviceValues);
const blogData    = buildPieData(blogValues);

データを「カテゴリ名」と「数値」に分けて持っています。カテゴリ名は両ページで共通(流入経路の種別)なので、categories 配列を1つだけ用意します。数値部分はページごとに異なるため、serviceValuesblogValues の2つの配列に分けています。

buildPieData は、categories と数値配列を組み合わせて、円グラフ用の { name, value } 形式の配列を作るヘルパー関数です。これを通すことで、serviceData は次のような配列になります。

[
  { name: 'オーガニック検索', value: 1320 },
  { name: 'SNS', value: 580 },
  { name: '参照元', value: 430 },
  { name: '直接', value: 310 },
  { name: 'その他', value: 180 }
]

カテゴリ名と数値を別々の配列に分けておくと、カテゴリを追加・削除するときに categories だけ書き換えれば済むので、データの整合性を保ちやすくなります。

createLegendFormatter

function createLegendFormatter(data) {
  const total = data.reduce((sum, item) => sum + item.value, 0);
  return (name) => {
    const item = data.find(d => d.name === name);
    if (!item) return name;
    const percent = ((item.value / total) * 100).toFixed(2);
    return `${name}:${item.value.toLocaleString()} PV(${percent}%)`;
  };
}

これは「凡例の各項目に表示する文字列を生成する関数」を返す関数です。

createLegendFormatter(serviceData) を呼ぶと、serviceData に対応したフォーマッタ関数が返ってきます。この関数を凡例の formatter に渡しておくと、EChartsが凡例の項目を表示するたびにその関数が呼ばれて、各項目に表示する文字列を組み立ててくれます。

返ってきた関数の中身を見てみると、受け取った name(カテゴリ名)から data.find(d => d.name === name) で対応するデータを探し、合計(total)との比から割合を計算して、「カテゴリ名:値 PV(割合)」の形式の文字列を返します。たとえば「オーガニック検索」を渡すと「オーガニック検索:1,320 PV(46.81%)」が返ってきます。

合計(total)は createLegendFormatter を呼んだタイミングで1回だけ計算しておけば、内側の関数の中からそのまま使い続けられます。このように、外側で用意した値を内側の関数から参照できるしくみがクロージャです。

スマホ幅では円グラフの外側にラベルを出さないため、この凡例側で「カテゴリ名・数値・割合」をすべて見せる、という設計です。

4)showChart と addEventListener:ボタン操作で表示・切り替える

let myChart = null;

function showChart(optionToSet, activeBtn) {
  if (!myChart) {
    myChart = echarts.init(chartDom);
  }
  myChart.setOption(optionToSet, true);

  [btnService, btnBlog].forEach(btn => btn.classList.remove('is-active'));
  activeBtn.classList.add('is-active');
}

btnService.addEventListener('click', () => showChart(optionService, btnService));
btnBlog.addEventListener('click', () => showChart(optionBlog, btnBlog));

ボタンが押されたときに、対応するグラフを表示する処理です。先に「ボタンを押すとどう動くか」を addEventListener で押さえてから、呼ばれる側の showChart の中身を見ていきます。

addEventListener

btnService.addEventListener('click', () => showChart(optionService, btnService));
btnBlog.addEventListener('click', () => showChart(optionBlog, btnBlog));

2つのボタンそれぞれに click イベントを紐づけて、押されたら showChart に対応する option とボタン要素を渡しています。showChart の中身は次で見ます。

showChart

function showChart(optionToSet, activeBtn) {
  if (!myChart) {
    myChart = echarts.init(chartDom);
  }
  myChart.setOption(optionToSet, true);

  [btnService, btnBlog].forEach(btn => btn.classList.remove('is-active'));
  activeBtn.classList.add('is-active');
}

グラフを描画して、押されたボタンをアクティブ状態にする関数です。グラフの描画には、EChartsの次の2つの関数を使います。

関数役割
echarts.init(domElement)チャートを操作するための「チャートインスタンス」を作ってHTML要素に紐づける
setOption(option)チャートインスタンスにグラフの設定を渡して実際に描画する

まず echarts.init(chartDom) でコンテナにチャートインスタンスを作ります。myChart は関数の外で確保しておき、if (!myChart) で初回のみ初期化します(二重初期化の防止)。

次に myChart.setOption(optionToSet, true) で設定を反映します。第2引数の true は「前の設定を残さず完全に置き換える」意味で、ページ切り替え時の混在を防ぎます。

最後に forEachclassList で、押されたボタンだけに is-active を付け直します。

5)resize:画面サイズに追従する

画面サイズが変わったとき(ブラウザの幅を変えたり、端末を回転させたり)に、EChartsに再描画させます。

window.addEventListener('resize', () => {
  if (myChart) myChart.resize();
});

myChart.resize() を呼ぶと、EChartsはコンテナの新しいサイズを取り直して、media クエリも再評価して、レイアウトを更新してくれます。if (myChart) のチェックは「まだボタンを押していない(=グラフが描画されていない)状態」で resize が走ったときに、null に対して .resize() を呼ばないようにするための保険です。

ドーナツグラフにするには

円グラフをドーナツグラフ(中央が空いている円グラフ)にするのは、series.radius を配列にするだけで済みます。今回はさらに、その空いた中央に「合計値」のテキストを表示する応用まで踏み込んで解説します。

完成デモを触ってみよう

ドーナツ版の完成デモは下記です。円グラフ版と同じくページ別の流入経路アクセス数を表示しますが、円の中央が空いてドーナツ形状になり、その穴の中に合計値が表示されます。

ボタンを押すと
グラフが表示されます

JavaScript のコード全体

HTML と CSS は円グラフ版とまったく同じです。JavaScript だけが変わります。

document.addEventListener('DOMContentLoaded', () => {
  const btnService = document.getElementById('serviceChartBtn');
  const btnBlog = document.getElementById('blogChartBtn');
  const chartDom = document.getElementById('echartsContainer');

  // コンテナが存在しない場合は処理を止める(エラー防止)
  if (!chartDom) return;

  let myChart = null;

  // 流入経路のカテゴリ(両グラフ共通)
  const categories = ['オーガニック検索', 'SNS', '参照元', '直接', 'その他'];

  // 各ページのアクセス数(categoriesと同じ順序で並べる)
  const serviceValues = [1320, 580, 430, 310, 180];
  const blogValues    = [2140, 980, 310, 220, 130];

  // categoriesとvaluesを組み合わせて、ドーナツグラフ用の { name, value } 配列を作るヘルパー
  function buildPieData(values) {
    return categories.map((name, i) => ({ name, value: values[i] }));
  }

  const serviceData = buildPieData(serviceValues);
  const blogData    = buildPieData(blogValues);

  // 凡例に「カテゴリ名:値 PV(パーセント)」を表示するためのフォーマッタ生成関数
  function createLegendFormatter(data) {
    const total = data.reduce((sum, item) => sum + item.value, 0);
    return (name) => {
      const item = data.find(d => d.name === name);
      if (!item) return name;
      const percent = ((item.value / total) * 100).toFixed(2);
      return `${name}:${item.value.toLocaleString()} PV(${percent}%)`;
    };
  }

  // ドーナツ中央のテキスト(「合計」+数値)を生成するヘルパー
  // viewport(PC/SP)ごとに位置とフォントサイズが変わるため、完全な定義で返す
  function buildCenterText(data, isPC) {
    const total = data.reduce((sum, item) => sum + item.value, 0);
    return [
      // 「合計」のラベル(上の小さい行)
      {
        type: 'text',
        left: 'center',
        top: isPC ? 205 : 120,
        style: {
          text: '合計',
          fontFamily: '"Noto Sans JP", sans-serif',
          fontSize: isPC ? 14 : 12,
          fontWeight: 300,
          fill: '#333',
          textAlign: 'center'
        }
      },
      // 合計値(下の大きい行)
      {
        type: 'text',
        left: 'center',
        top: isPC ? 230 : 140,
        style: {
          text: `${total.toLocaleString()} PV`,
          fontFamily: '"Noto Sans JP", sans-serif',
          fontSize: isPC ? 24 : 20,
          fontWeight: 700,
          fill: '#333',
          textAlign: 'center'
        }
      }
    ];
  }

  // mediaを生成するヘルパー(PC/SPそれぞれ、graphicは完全な定義で含める)
  // graphicの内容がdataに依存するため、media自体もデータごとに作る
  function buildMedia(data) {
    return [
      {
        // PC幅:凡例を非表示、ドーナツグラフを中央配置、タイトルは大きめのフォントサイズ
        query: { minWidth: 740 },
        option: {
          title: { textStyle: { fontSize: 18 } },
          legend: { show: false },
          tooltip: { show: true },
          series: [{
            // ドーナツ:内側40%, 外側70%
            radius: ['40%', '70%'],
            center: ['50%', '230px'],
            label: {
              show: true,
              formatter: '{b}\n{d}%',
              lineHeight: 19
            }
          }],
          graphic: buildCenterText(data, true)
        }
      },
      {
        // スマホ幅:凡例を縦並びで下に、ホバー動作は無効化、タイトルは小さめのフォントサイズ
        query: { maxWidth: 739 },
        option: {
          title: { textStyle: { fontSize: 16 } },
          legend: {
            show: true,
            orient: 'vertical',
            bottom: 0,
            left: 'center'
          },
          // スマホはタップ操作のため、ツールチップも非表示
          tooltip: { show: false },
          series: [{
            // ドーナツ:内側35%, 外側55%
            radius: ['35%', '55%'],
            center: ['50%', '140px'],
            label: { show: false },
            // スマホはタップ操作のため、ホバー時のハイライト動作を無効化
            emphasis: { disabled: true }
          }],
          graphic: buildCenterText(data, false)
        }
      }
    ];
  }

  // 2つのグラフで共通する設定をまとめたベースoption(mediaはoption側で生成)
  const baseOption = {
    // ドーナツの各スライスに使う色(dataの順番に適用される)
    color: ['#c11a51', '#4466ce', '#f9d77a', '#93c98c', '#c9cde0'],
    // グラフ全体の文字スタイル
    textStyle: {
      color: '#333',
      fontFamily: '"Noto Sans JP", sans-serif',
      fontSize: 14,
      fontWeight: 300
    },
    // 凡例の共通設定(表示・非表示や向きはmedia側で切り替え)
    legend: {
      itemGap: 12,
      itemWidth: 16,
      itemHeight: 12,
      selectedMode: false
    },
    // ツールチップ(ホバー時・タップ時の情報表示)
    tooltip: {
      trigger: 'item',
      formatter: (params) =>
        `${params.name}<br>${params.value.toLocaleString()} PV(${params.percent}%)`,
      textStyle: {
        color: '#333',
        fontFamily: '"Noto Sans JP", sans-serif',
        fontSize: 12,
        fontWeight: 300
      },
      padding: 15
    }
  };

  // サービス紹介ページのグラフoption(baseOptionに差分を追加)
  const optionService = {
    ...baseOption,
    title: {
      text: 'サービス紹介ページ 流入経路別アクセス数',
      top: 0,
      left: 'center'
    },
    legend: {
      ...baseOption.legend,
      formatter: createLegendFormatter(serviceData)
    },
    series: [{
      name: '流入経路',
      type: 'pie',
      data: serviceData,
      labelLine: { show: true },
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        }
      }
    }],
    // PC/SPそれぞれの上書きとドーナツ中央テキスト(合計+数値)を含む
    media: buildMedia(serviceData)
  };

  // ブログ記事ページのグラフoption(baseOptionに差分を追加)
  const optionBlog = {
    ...baseOption,
    title: {
      text: 'ブログ記事ページ 流入経路別アクセス数',
      top: 0,
      left: 'center'
    },
    legend: {
      ...baseOption.legend,
      formatter: createLegendFormatter(blogData)
    },
    series: [{
      name: '流入経路',
      type: 'pie',
      data: blogData,
      labelLine: { show: true },
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        }
      }
    }],
    // PC/SPそれぞれの上書きとドーナツ中央テキスト(合計+数値)を含む
    media: buildMedia(blogData)
  };

  // グラフを描画し、押したボタンをアクティブにする関数
  function showChart(optionToSet, activeBtn) {
    // まだ初期化していない場合のみecharts.init()を呼ぶ(二重初期化防止)
    if (!myChart) {
      myChart = echarts.init(chartDom);
    }
    // 第2引数のtrueで前の設定を残さず新しいoptionに切り替える
    myChart.setOption(optionToSet, true);

    // ボタンのアクティブ状態を切り替える
    [btnService, btnBlog].forEach(btn => btn.classList.remove('is-active'));
    activeBtn.classList.add('is-active');
  }

  btnService.addEventListener('click', () => showChart(optionService, btnService));
  btnBlog.addEventListener('click', () => showChart(optionBlog, btnBlog));

  // 画面サイズ変更時にグラフを再描画(描画済みの場合のみ)
  window.addEventListener('resize', () => {
    if (myChart) myChart.resize();
  });
});

JavaScript の変更点を、次の3つのトピックに分けて順番に見ていきます。

① 中央テキストの表示と、media を関数化する必要性
② buildMedia と buildCenterText で組み立てる
③ radius を配列にしてドーナツ形状にする

変更内容の解説

① 中央テキストの表示と、media を関数化する必要性

ドーナツの真ん中に「合計」と数値(中央テキスト)を表示するために、EChartsの graphic を使います。graphic は、グラフの上に自由配置でテキストや図形を置ける仕組みです。

中央テキストは、PC幅では下のほうに大きめ、スマホ幅では上のほうに小さめ、というように画面幅で位置とフォントサイズが変わります。さらに、表示する数値(「2,820 PV」「3,780 PV」)もページごとに違います。

前提

円グラフ版の構造を踏まえると、graphic も同じパターンで次の3つの層に分けて書きたいところです。

  • 共通部分(「合計」のラベル、色、フォントなど)→ baseOption.graphic
  • PC/スマホで変わる部分(topfontSize)→ baseOption.media 内の graphic
  • ページによって変わる部分(数値)→ optionService.graphic / optionBlog.graphic
事情

ところが、EChartsの仕様上、graphic に対しては「差分だけ上書き」が正しく動きません。差分だけを書くと正しく描画されないため、graphic の中身は PC幅・スマホ幅それぞれに完全な定義として書く必要があります

結論

graphic の完全な定義にはページごとに違う数値も含まれるため、mediabaseOption には置けず、optionService / optionBlog の側で組み立てる必要があります。そこで、media を関数 buildMedia(data) にして、データを引数で受け取って組み立てる形にしました。optionService 側では media: buildMedia(serviceData)、optionBlog 側では media: buildMedia(blogData) を呼ぶことで、それぞれに合った数値が入った media が生成されます。

const optionService = {
  ...baseOption,
  // ...
  media: buildMedia(serviceData)
};

const optionBlog = {
  ...baseOption,
  // ...
  media: buildMedia(blogData)
};

② buildMedia と buildCenterText で組み立てる

buildMedia の中身を見ていきます。

function buildMedia(data) {
  return [
    {
      query: { minWidth: 740 },
      option: {
        title: { textStyle: { fontSize: 18 } },
        legend: { show: false },
        tooltip: { show: true },
        series: [{
          radius: ['40%', '70%'],
          center: ['50%', '230px'],
          label: { show: true, formatter: '{b}\n{d}%', lineHeight: 19 }
        }],
        graphic: buildCenterText(data, true)
      }
    },
    {
      query: { maxWidth: 739 },
      option: {
        title: { textStyle: { fontSize: 16 } },
        legend: { show: true, orient: 'vertical', bottom: 0, left: 'center' },
        tooltip: { show: false },
        series: [{
          radius: ['35%', '55%'],
          center: ['50%', '140px'],
          label: { show: false },
          emphasis: { disabled: true }
        }],
        graphic: buildCenterText(data, false)
      }
    }
  ];
}

titlelegendtooltipseries の設定は円グラフ版と同じ内容です(series.radius のドーナツ化は③で扱います)。今回新しく登場するのが graphic プロパティで、各 media(PC幅とスマホ幅)の中に置き、buildCenterText(data, true / false) で中身を組み立てます。第2引数は「PC幅か」を示す真偽値で、PC のときは true、スマホのときは false です。

続いて buildCenterText の中身です。

function buildCenterText(data, isPC) {
  const total = data.reduce((sum, item) => sum + item.value, 0);
  return [
    {
      type: 'text',
      left: 'center',
      top: isPC ? 205 : 120,
      style: {
        text: '合計',
        fontFamily: '"Noto Sans JP", sans-serif',
        fontSize: isPC ? 14 : 12,
        fontWeight: 300,
        fill: '#333',
        textAlign: 'center'
      }
    },
    {
      type: 'text',
      left: 'center',
      top: isPC ? 230 : 140,
      style: {
        text: `${total.toLocaleString()} PV`,
        fontFamily: '"Noto Sans JP", sans-serif',
        fontSize: isPC ? 24 : 20,
        fontWeight: 700,
        fill: '#333',
        textAlign: 'center'
      }
    }
  ];
}

中央テキストは2行構成で、上に「合計」のラベル、下にその数値を表示します。

graphic は配列で、各要素が1つの図形やテキストを表します。要素ごとに type で種類を指定し(テキストなら 'text')、left / top などで位置を指定、テキスト本文やフォントなどの見た目は style の中にまとめる、というのが基本構造です。

buildCenterText でも同じ要領で、テキスト要素(type: 'text')を2つ書き、それぞれ left: 'center' で水平中央に置き、top の値で縦位置を指定しています。isPC を見て、PC とスマホで topfontSize を切り替えます。

style.text の中身は、上の行が固定で '合計'、下の行は data から合計を計算して '2,820 PV' のような文字列を組み立てて渡します。

③ radius を配列にしてドーナツ形状にする

最後にドーナツ形状そのものについてです。円グラフをドーナツにするには、series.radius を単一の値から配列に変えるだけです。

// 円グラフ:単一の値(外側の半径)
radius: '70%'

// ドーナツグラフ:配列 [内側の半径, 外側の半径]
radius: ['40%', '70%']

radius を配列にすると、[内側の半径, 外側の半径] の指定となり、内側部分が空いてドーナツ形状になります。外側の値は円グラフ版と同じ '70%'、内側を '40%' にすることで、ちょうどよいリング幅になります。中央の穴を大きくしたいときは内側の値を増やし、リングを太くしたいときは内側を小さくします。

スマホ幅では ['35%', '55%'] のように両方を小さめに調整して、画面サイズに合わせています。

まとめ

この記事では、Apache EChartsを使って円グラフとドーナツグラフを実装し、デザイン調整・レスポンシブ対応・ボタン切り替え・ドーナツの中央テキスト表示まで一通り解説しました。

ポイントは下記です。

  • 円グラフは series.type: 'pie' で描画する。スライスの色は option レベルの color 配列で一括指定する
  • baseOption の共通設定に、optionService / optionBlog の差分を足す設計で、複数のグラフを書き分けられる
  • 凡例フォーマッタは createLegendFormatter のようにクロージャで作っておくと、合計値の計算を1回で済ませられて効率的
  • ドーナツにするには series.radius[内側, 外側] の配列にする
  • ドーナツの中央テキストは graphic で配置できる。EChartsの media と相性のクセがあるため、media を関数化してデータごとに組み立てる

円グラフは「カテゴリ数が少ない構成比」を示すのに向いた表現です。今回のコードはデータやカテゴリ名を差し替えるだけで、PV内訳以外にも売上構成・アンケート結果・カテゴリ別売上などにそのまま使えます。ぜひご自分の案件に合わせてカスタマイズしてみてください。

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました