デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
流入元の割合を、ドーナツの区分として 12 時から時計回りに 1 つずつ埋めていきます。区分はそれぞれ 1 本の円で、見えている長さと始まりの位置だけを書き換えて弧にします。円に、線の長さを 100 として扱わせる指定(pathLength)を付けておくと、割合の % をそのまま長さとして書けます。中央の合計と、表の割合とセッション数も、区分と一緒に 0 から数え上げます。
1 つの区分を埋める時間は、0.35 秒に「割合 × 1.4 秒」を足したもので、大きい区分ほど長くかかります。次の区分は前の区分が 8 割まで進んだところから始めるので、途切れずにひと続きで一周します。区分は power2.inOut で埋め、合計は全体の時間をかけて power2.out で数えます。区分の間には円周の 0.5% ほどのすき間を空けて、色の境目を読みやすくします。
見えている長さの指定(stroke-dasharray)は、CSS に書かないでください。CSS は SVG の属性より強いので、スクリプトで書き換えても効かず、弧がいつまでも動きません。値は要素の style に直接書きます。
グラフの見た目だけでは、読み上げに何も届きません。流入元・セッション数・割合は表としてページに書き、スクリプトはその表を読んで輪を描きます。値を持つ場所が表だけなので、数字を直せば輪も直ります。表の行は透明度だけで出し、出る前から値が読み上げに届くようにします。描くのは画面に入った 1 回だけで、動きを減らす設定では埋まりきった状態で見せます。
輪の直径と太さ、区分の色は CSS の値で変えられます。色は大きい区分から濃い → 淡いの順に明るさを離して並べると、隣り合う区分を色相に頼らず見分けられます。区分が多いと細い弧が並んで読めなくなるので、5 つほどまでにして、残りは「その他」にまとめます。アクセス解析の流入元や売上の内訳、アンケートの結果のように、全体に対する割合を見せたい節に向いています。
コード
<figure class="donut">
<figcaption class="donut__caption">
<span class="donut__eyebrow">Traffic sources</span>
<span class="donut__title">流入元の内訳</span>
</figcaption>
<div class="donut__body">
<div class="donut__chart">
<!-- 区分の輪は、下の表の値から JS が描く -->
<svg class="donut__svg" viewBox="0 0 200 200" aria-hidden="true">
<circle class="donut__track" cx="100" cy="100" r="80" />
</svg>
<p class="donut__total"><b class="donut__sum">48,210</b>今月のセッション数</p>
</div>
<!-- 値はこの表が正。区分の割合と色(--color)も、ここから読む -->
<table class="donut__table">
<thead>
<tr><th scope="col">流入元</th><th scope="col">セッション数</th><th scope="col">割合</th></tr>
</thead>
<tbody>
<tr style="--color: #475c6a"><th scope="row">自然検索</th><td>20,248</td><td>42%</td></tr>
<tr style="--color: #62839d"><th scope="row">直接アクセス</th><td>12,535</td><td>26%</td></tr>
<tr style="--color: #c3ab90"><th scope="row">SNS</th><td>8,678</td><td>18%</td></tr>
<tr style="--color: #d5c3af"><th scope="row">外部サイト</th><td>6,749</td><td>14%</td></tr>
</tbody>
</table>
</div>
</figure>
.donut {
--bg: #f2f1ed;
--ink: #262626;
--mute: #898989; /* 中央の注記 */
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgba(38, 38, 38, 0.12); /* 輪の下地と、行の区切り */
--size: 320px; /* 輪の直径 */
--stroke: 24; /* 輪の太さ(viewBox 200 の中の値) */
margin: 0;
padding: 48px clamp(24px, 6vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.donut__caption { display: grid; gap: 16px; margin-bottom: 40px; }
.donut__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.donut__title { font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.donut__body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 48px 64px;
}
.donut__chart { position: relative; flex: none; width: min(var(--size), 100%); }
.donut__svg { display: block; width: 100%; height: auto; }
/* 区分の見える長さは JS が要素の style に書く。ここで stroke-dasharray を宣言しない */
.donut__track,
.donut__arc { fill: none; stroke-width: var(--stroke); }
.donut__track { stroke: var(--line); }
.donut__total {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
margin: 0;
color: var(--mute);
font-size: 14px;
letter-spacing: 0.04em;
text-align: center;
}
.donut__sum { color: var(--ink); font-size: 48px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
/* 区切り線はセルに持たせ、行と一緒に出す(collapse にすると線は表が描くので、行より先に見える) */
.donut__table { flex: 1 1 320px; border-spacing: 0; }
.donut__table th,
.donut__table td { padding: 16px 0 16px 24px; border-top: 1px solid var(--line); text-align: right; white-space: nowrap; }
.donut__table th:first-child { padding-left: 0; text-align: left; }
.donut__table thead th { padding-block: 0 12px; border: 0; color: var(--mute-small); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; }
.donut__table tbody th { font-size: 18px; font-weight: 600; letter-spacing: 0.04em; }
.donut__table td { color: var(--mute-small); font-size: 14px; }
.donut__table td:last-child { color: var(--ink); font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
/* 行の色の四角は、行に書いた --color から */
.donut__table tbody th::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
margin-right: 16px;
border-radius: 4px;
background: var(--color);
vertical-align: -2px;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const GAP = 0.5; // 区分の間のすき間(円周を 100 としたときの長さ)
const EASE = "power2.inOut";
const format = (v) => Math.round(v).toLocaleString("ja-JP");
document.querySelectorAll(".donut").forEach((chart) => {
const svg = chart.querySelector(".donut__svg");
const sum = chart.querySelector(".donut__sum");
// 値は表から読む。値を持つ場所が 1 つなので、表を直せば輪も数字も直る
const rows = [...chart.querySelectorAll("tbody tr")].map((row) => {
const [count, share] = [row.cells[1], row.cells[2]].map((cell) => Number(cell.textContent.replace(/[^\d.]/g, "")));
return { row, count, share, fill: 0 };
});
const total = rows.reduce((all, r) => all + r.count, 0);
const state = { total: 0 };
// 区分ごとに円を 1 本ずつ重ねる。線の長さを 100 として扱わせると、割合の % をそのまま長さとして書ける
let start = 0;
for (const r of rows) {
r.start = start;
start += r.share;
r.arc = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const attrs = { class: "donut__arc", cx: 100, cy: 100, r: 80, pathLength: 100, transform: "rotate(-90 100 100)" };
for (const [key, value] of Object.entries(attrs)) r.arc.setAttribute(key, value);
r.arc.style.stroke = getComputedStyle(r.row).getPropertyValue("--color");
r.arc.style.strokeDasharray = "0 100"; // 描き始めるまでは空にしておく
svg.append(r.arc);
}
// dasharray と dashoffset は要素の style に書く(CSS で宣言すると、CSS が勝って動かない)
const draw = () => {
for (const r of rows) {
const length = Math.max(0, r.fill - GAP);
r.arc.style.strokeDasharray = `${length} 100`;
r.arc.style.strokeDashoffset = String(-(r.start + GAP / 2));
r.row.cells[1].textContent = format((r.fill / r.share) * r.count);
r.row.cells[2].textContent = `${Math.round(r.fill)}%`;
}
sum.textContent = format(state.total);
};
// 大きい区分ほど長く埋め、次の区分は前の区分の 8 割の時点から始める
const tl = gsap.timeline({ paused: true, onUpdate: draw });
let at = 0;
let end = 0;
for (const r of rows) {
const duration = 0.35 + (r.share / 100) * 1.4;
tl.to(r, { fill: r.share, duration, ease: EASE }, at)
// 行は透明度だけで出す(visibility で隠すと、描く前の値が読み上げに届かない)
.from(r.row, { opacity: 0, x: 18, duration: 0.55, ease: "power3.out" }, at);
end = at + duration;
at += duration * 0.8;
}
tl.to(state, { total, duration: end, ease: "power2.out" }, 0).from(sum.parentElement, { opacity: 0, duration: 0.4 }, 0);
// 動きを減らす設定では、埋まりきった状態で見せる
if (reduceMotion) {
tl.progress(1);
return;
}
// 画面に入ったら 1 回だけ描く。スクロールのたびに描き直すと、値を追えない
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
tl.play();
}, { rootMargin: "0px 0px -20%" }); // 下から 2 割入ったら始める
io.observe(chart);
});