本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
グラフ

レーダーグラフが中心から広がり、2 つの系列を比べる

デモ

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

6つの項目の目盛りを持つ多角形の上に、2つの製品の値を表す多角形を重ねます。各頂点を中心から半径0まで縮めた状態から、時計回りに少しずつ遅らせながら伸ばすので、形がほどけるように開きます。

1つの頂点が伸びる時間は1秒、隣の頂点とは0.08秒ずつずらします。2つ目の系列はさらに0.4秒遅れて重なるので、1つ目が開き終わる頃に2つ目が追いかけてきます。塗りの不透明度は0.18に抑え、重なっても下の多角形と目盛りが透けて見えるようにしています。2つの色は色相だけでなく明るさも離しているので、色の違いが分かりにくい人にも、濃い線と淡い線として見分けられます。

項目名と2つの系列の値は、右に添えた表の行と列からそのまま読みます。表の見出しに書いた色(--color)もgetComputedStyleで読み、多角形と点の色に使うので、色を変えたいときは表のスタイルを直すだけで済みます。値が伸びるあいだ、表のセルの数字も同じ位置から数え上げ直すので、図と数字がずれません。

頂点の伸びる速さや遅らせ方、目盛りの段階は、どれもスクリプトの先頭で変えられます。画面に入ったときに一度だけ開き、動きを減らす設定では最初から開ききった状態で置きます。製品やプランの比較、アンケートの項目別スコアのように、複数の指標を並べて見せたい場面に向いています。

コード

HTML
<figure class="radar">
  <figcaption class="radar__caption">
    <span class="radar__eyebrow">Product comparison</span>
    <span class="radar__title">2機種の比較</span>
  </figcaption>

  <div class="radar__body">
    <!-- 目盛りと 2 つの多角形は、右の表の項目と値から JS が描く -->
    <svg class="radar__svg" viewBox="0 0 320 320" aria-hidden="true"></svg>

    <!-- 値はこの表が正。行が項目、列が比べる機種。列の色は --color -->
    <table class="radar__table">
      <thead>
        <tr>
          <th scope="col">項目</th>
          <th scope="col" style="--color: #475c6a">上位モデル</th>
          <th scope="col" style="--color: #ba7069">軽量モデル</th>
        </tr>
      </thead>
      <tbody>
        <tr><th scope="row">処理性能</th><td>92</td><td>68</td></tr>
        <tr><th scope="row">バッテリー</th><td>64</td><td>88</td></tr>
        <tr><th scope="row">画面</th><td>90</td><td>72</td></tr>
        <tr><th scope="row">カメラ</th><td>84</td><td>66</td></tr>
        <tr><th scope="row">携帯性</th><td>52</td><td>90</td></tr>
        <tr><th scope="row">コスパ</th><td>58</td><td>86</td></tr>
      </tbody>
    </table>
  </div>
</figure>

CSS
.radar {
  --ink: #262626;
  --mute: #6b6b6b;
  --surface: #ffffff;
  --band: #f2f1ed;  /* 目盛りの帯(交互)の色 */
  --line: rgba(38, 38, 38, 0.12);
  margin: 0;
  padding: 32px;
  background: var(--surface);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.radar__caption { display: grid; gap: 6px; margin-bottom: 24px; }
.radar__eyebrow { font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.radar__title { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; }

.radar__body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 32px 48px;
}

.radar__svg { display: block; flex: none; width: min(320px, 100%); height: auto; overflow: visible; } /* 上の軸の名前の字の上端が、枠で切れないように */

.radar__ring { fill: none; stroke: var(--line); stroke-width: 1; }
.radar__axis { stroke: var(--line); stroke-width: 1; }

.radar__label {
  fill: var(--ink);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.radar__table { flex: 1 1 260px; border-spacing: 0; font-size: 14px; }

.radar__table th,
.radar__table td {
  padding: 8px 0 8px 16px;
  border-top: 1px solid var(--line);
  text-align: right;
  white-space: nowrap;
}

.radar__table th[scope="row"] { text-align: left; padding-left: 0; }

.radar__table thead th {
  border-top: 0;
  padding-bottom: 10px;
  color: var(--mute);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
}

.radar__table thead th[scope="row"],
.radar__table thead th:first-child { padding-left: 0; text-align: left; }

/* 列の色の四角は、見出しに書いた --color から */
.radar__table thead th[style]::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: 8px;
  border-radius: 50%;
  background: var(--color);
  vertical-align: 1px;
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SVG_NS = "http://www.w3.org/2000/svg";
const MAX = 100; // 目盛りの上端
const LEVELS = [20, 40, 60, 80, 100];
const GROW = 1; // 1 つの頂点が伸びる時間(秒)
const EACH = 0.08; // 隣の頂点とのずれ
const LAG = 0.4; // 2 つ目の系列の遅れ
const DOT_R = 5;

document.querySelectorAll(".radar").forEach((chart) => {
  const svg = chart.querySelector(".radar__svg");
  const size = 320;
  const cx = size / 2;
  const cy = size / 2;
  const r = size * 0.42;

  const node = (tag, attrs) => {
    const n = document.createElementNS(SVG_NS, tag);
    for (const [key, v] of Object.entries(attrs)) n.setAttribute(key, v);
    svg.append(n);
    return n;
  };

  // 表から読む: 項目名(行)、系列名と色(列)、値(セル)
  const rows = [...chart.querySelectorAll("tbody tr")];
  const axes = rows.map((row) => row.querySelector("th").textContent);
  const heads = [...chart.querySelectorAll("thead th")].slice(1);
  const series = heads.map((th, k) => ({
    name: th.textContent,
    color: getComputedStyle(th).getPropertyValue("--color").trim(),
    // row.cells には行自身の th(項目名)も含むので、値の列は 1 つ右にずれる
    cells: rows.map((row) => row.cells[k + 1]),
  }));
  const values = series.map((s) => s.cells.map((cell) => Number(cell.textContent)));

  const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / axes.length;
  const point = (i, v) => {
    const d = (v / MAX) * r;
    return [cx + Math.cos(angle(i)) * d, cy + Math.sin(angle(i)) * d];
  };

  // 目盛り: 外側の多角形から順に重ねる
  [...LEVELS].reverse().forEach((level) => {
    node("polygon", { points: axes.map((_, i) => point(i, level).join(",")).join(" "), class: "radar__ring" });
  });
  axes.forEach((name, i) => {
    const [x, y] = point(i, MAX);
    node("line", { x1: cx, y1: cy, x2: x.toFixed(1), y2: y.toFixed(1), class: "radar__axis" });
    const [lx, ly] = point(i, MAX * 1.13);
    const label = node("text", { x: lx.toFixed(1), y: ly.toFixed(1), class: "radar__label", "text-anchor": "middle" });
    label.textContent = name;
  });

  const shapes = series.map((s) => node("polygon", { points: "", fill: s.color, "fill-opacity": 0.18, stroke: s.color, "stroke-width": 2.5, "stroke-linejoin": "round" }));
  const dots = series.map((s) => axes.map(() => node("circle", { cx, cy, r: 0, fill: s.color })));

  // 頂点ごとの半径 { r }。系列ごとに LAG ずつ遅らせ、頂点は 12 時から時計回りに EACH ずつずらす
  const radii = series.map((s) => axes.map(() => ({ r: 0 })));
  const draw = () => {
    series.forEach((s, k) => {
      const pts = radii[k].map(({ r: rr }, i) => point(i, rr));
      shapes[k].setAttribute("points", pts.map((p) => p.map((n) => n.toFixed(1)).join(",")).join(" "));
      dots[k].forEach((dot, i) => {
        dot.setAttribute("cx", pts[i][0].toFixed(1));
        dot.setAttribute("cy", pts[i][1].toFixed(1));
        dot.setAttribute("r", (DOT_R * Math.min(1, radii[k][i].r / 12)).toFixed(1));
      });
      s.cells.forEach((cell, i) => (cell.textContent = String(Math.round(radii[k][i].r))));
    });
  };

  const tl = gsap.timeline({ paused: true, onUpdate: draw });
  series.forEach((s, k) => {
    tl.to(radii[k], { r: (i) => values[k][i], duration: GROW, ease: "power3.out", stagger: EACH }, k * LAG);
  });

  // 動きを減らす設定では、広がりきった状態で見せる
  if (reduceMotion) {
    tl.progress(1);
    return;
  }

  // 画面に入ったら 1 回だけ広げる
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    tl.play();
  }, { rootMargin: "0px 0px -20%" });
  io.observe(chart);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。