デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
上のタブで「棒グラフ」を押すと、ドーナツ型の円グラフの扇が 1 枚ずつほどけてまっすぐに伸び、同じ値の長さの横棒へ形を変えて、左の項目名の横に並びます。「円グラフ」を押すと、同じ道をたどって輪に戻ります。扇の輪の真ん中の弧の長さを、そのまま棒の長さにしているので、割合で見る形と量で比べる形が、同じ図形の読み替えだと伝わります。扇を浮かせたり、輪を順に埋めたり、棒の値を入れ替えたりするのではなく、図形そのものを別のグラフへ変えます。
扇は、真ん中の点・その点での向き・曲がり(1 ÷ 半径)・長さ・太さの 5 つで決まる帯として描きます。曲がりを 1 ÷ 半径から 0 へ、向きを扇の真ん中の向きから横へ、真ん中の点を扇から棒の中央へ動かし、長さだけは保つので、弧が伸びきったときにちょうど値の長さの棒になります。帯は真ん中の線を 40 等分し、それぞれの点を法線の向きへ太さの半分ずらして結びます。形を点の補間で変える道具を使わずに、式だけで描けます。
1 枚は 1.1 秒で形を変え、次の扇は 0.12 秒遅れて始めます。曲がりと向きは power2.inOut で先の 4 分の 3 のうちにほどき、位置と太さは power3.inOut で少し遅れて動かすので、まず弧が開き、それから棒の列へ滑り込みます。棒はどちら向きでも同じ見た目なので、扇の真ん中の向きに近いほう(0° か 180°)へ回し、回す角度を 90° までに抑えます。途中で押し直しても、形の進みは時間どおりに進めて ease は描くときにかけるので、今の形から同じ道を引き返します。
値のラベルは形に付いて動きます。円グラフでは扇の真ん中に構成比を置き、形が変わり始めると消えます。棒グラフでは棒の右の端に金額を付け、形が変わり終わる 3 割のあいだに出します。前の文字が消えきってから次の文字を出すので、途中で 2 つが重なって読めなくなることはありません。値は表としてページに書き、スクリプトはその表を読んで扇を組みます。組んだあとの表は見えなくするだけにして読み上げに残し、切り替えのボタンは aria-pressed で今の形を伝えます。動きを減らす設定では、形を変えずに一瞬で切り替えます。
つまみは、輪の真ん中の半径(棒の長さの縮尺になります)、輪と棒の太さ、1 枚の時間と遅れです。項目は 5〜6 つまでにし、小さい項目が増えるときは「その他」にまとめます。売上や予算の構成を、割合と金額の両方から見せたい節や、ダッシュボードの表示の切り替えに向いています。
コード
<figure class="pie" data-unit="万円">
<figcaption class="pie__caption">
<span class="pie__eyebrow">Sales mix</span>
<span class="pie__title">カテゴリ別の売上</span>
<span class="pie__note"><span>2026年9月・</span><span>商品カテゴリ別・</span><span>単位:万円</span></span>
</figcaption>
<!-- 押すと、同じ値のまま円グラフと棒グラフを行き来する -->
<div class="pie__switch" role="group" aria-label="グラフの形">
<button type="button" aria-pressed="true">円グラフ</button>
<button type="button" aria-pressed="false">棒グラフ</button>
</div>
<!-- 扇・棒・ラベルはスクリプトが表から描く(絵なので読み上げからは外し、値は下の表で届ける) -->
<div class="pie__stage" aria-hidden="true">
<svg class="pie__svg"></svg>
<p class="pie__total">合計<b>1,100<small>万円</small></b></p>
</div>
<!-- 値はこの表が正。上から順に、円グラフでは 12 時から時計回りに並ぶ -->
<table class="pie__table">
<caption>商品カテゴリ別の売上(2026年9月・万円)</caption>
<thead>
<tr><th scope="col">カテゴリ</th><th scope="col">売上</th><th scope="col">構成比</th></tr>
</thead>
<tbody>
<tr><th scope="row">家具</th><td>420</td><td>38%</td></tr>
<tr><th scope="row">照明</th><td>260</td><td>24%</td></tr>
<tr><th scope="row">雑貨</th><td>200</td><td>18%</td></tr>
<tr><th scope="row">食器</th><td>140</td><td>13%</td></tr>
<tr><th scope="row">寝具</th><td>80</td><td>7%</td></tr>
</tbody>
</table>
</figure>
.pie {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgb(38 38 38 / 0.12);
/* 項目の色(上から)。大きい項目ほど濃く、明るさの差で見分ける */
--c0: #35607c;
--c1: #4686b9;
--c2: #6b9ac7;
--c3: #92b3d3;
--c4: #b4cde5;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
margin: 0;
padding: 48px clamp(16px, 5vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.pie__caption { display: grid; gap: 10px; }
.pie__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.pie__title { font-size: clamp(28px, 6vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.pie__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.pie__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */
/* 丸帯の切り替え。つまみ(::before)が押した側へ滑る */
.pie__switch { position: relative; display: grid; grid-template-columns: 1fr 1fr; align-self: start; padding: 4px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); }
.pie__switch::before { content: ""; position: absolute; inset: 4px auto 4px 4px; width: calc(50% - 4px); border-radius: 999px; background: var(--ink); transition: translate 0.45s cubic-bezier(0.65, 0, 0.35, 1); }
.pie[data-mode="bars"] .pie__switch::before { translate: 100%; }
.pie__switch button { position: relative; min-width: 108px; height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: none; color: var(--ink); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; transition: color 0.3s; }
.pie__switch button[aria-pressed="true"] { color: #ffffff; }
.pie__switch button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.pie__stage { grid-column: 1 / -1; position: relative; }
.pie__svg { display: block; width: 100%; overflow: visible; }
.pie__svg .name { fill: var(--ink); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; dominant-baseline: central; }
.pie__svg .pct { font-size: 20px; font-weight: 800; text-anchor: middle; dominant-baseline: central; }
.pie__svg .pct { fill: var(--ink); }
.pie__svg .pct[data-k="0"], .pie__svg .pct[data-k="1"] { fill: #ffffff; } /* 濃い 2 つの扇の上は白い文字(20px の太字なので 3:1 で足りる) */
.pie__svg .value { fill: var(--ink); font-size: 15px; font-weight: 700; dominant-baseline: central; }
.pie__svg .slice { stroke: var(--bg); stroke-width: 2; stroke-linejoin: round; } /* 扇の間を地の色で 2px 空ける */
.pie__svg .axis line { stroke: var(--line); }
.pie__svg .axis text { fill: var(--mute-small); font-size: 13px; text-anchor: middle; }
.pie__total { position: absolute; display: grid; justify-items: center; gap: 6px; margin: 0; color: var(--mute-small); font-size: 14px; letter-spacing: 0.04em; translate: -50% -50%; pointer-events: none; }
.pie__total b { color: var(--ink); font-size: 32px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
.pie__total small { margin-left: 2px; font-size: 15px; font-weight: 700; letter-spacing: 0; }
/* 狭い画面: 切り替えを見出しの下に置き、文字を小さくする(大きさはスクリプトが幅から決める) */
@media (max-width: 640px) {
.pie { grid-template-columns: minmax(0, 1fr); }
.pie__svg .name, .pie__svg .value { font-size: 13px; }
.pie__svg .pct { font-size: 13px; }
.pie__svg .axis text:last-child { text-anchor: start; } /* 単位の付いた最後の目盛りを、隣の数字に重ねない */
.pie__total { font-size: 12px; }
.pie__total b { font-size: 20px; }
.pie__total small { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.pie__switch::before, .pie__switch button { transition: none; }
}
/* 読み上げだけに届ける。表は、スクリプトが絵を描くまで見えたまま残す */
.pie.is-live .pie__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.pie__table { grid-column: 1 / -1; width: 100%; border-spacing: 0; }
.pie__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.pie__table th,
.pie__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.pie__table th:first-child { text-align: left; }
.pie__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }
import { gsap } from "gsap";
// 動きを減らす設定では、形を変えずに一瞬で切り替える
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const set = (el, attrs) => Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value));
const node = (tag, parent, attrs = {}, text = "") => {
const el = parent.appendChild(document.createElementNS("http://www.w3.org/2000/svg", tag));
set(el, attrs);
return Object.assign(el, { textContent: text });
};
const clamp01 = gsap.utils.clamp(0, 1);
const lerp = (a, b, k) => a + (b - a) * k;
const bend = gsap.parseEase("power2.inOut"); // ほどける(曲がりと向き)
const travel = gsap.parseEase("power3.inOut"); // 動く(位置と太さ)
document.querySelectorAll(".pie").forEach((chart) => {
const svg = chart.querySelector(".pie__svg");
const total = chart.querySelector(".pie__total");
const buttons = [...chart.querySelectorAll(".pie__switch button")];
const unit = chart.dataset.unit;
const axis = node("g", svg, { class: "axis" });
// 値は表が正。上から順に、12 時から時計回りに扇を並べる
const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => [...tr.cells].map((cell) => cell.textContent));
const sum = rows.reduce((all, row) => all + Number(row[1]), 0);
let angle = 0;
const slices = rows.map(([name, amount, share], k) => {
const value = Number(amount);
const mid = angle + (value / sum) * Math.PI;
angle += (value / sum) * Math.PI * 2;
return {
value, share: value / sum, mid, p: 0, // p: 形の進み(0 で扇、1 で棒)
flat: Math.round(mid / Math.PI) * Math.PI, // 棒の向き。扇の真ん中の向きに近い 0 か 180°(棒はどちら向きでも同じ見た目)
swatch: node("rect", svg, { width: 14, height: 14, rx: 2, style: `fill: var(--c${k})` }), name: node("text", svg, { class: "name", x: 22 }, name),
path: node("path", svg, { class: "slice", style: `fill: var(--c${k})` }),
pct: node("text", svg, { class: "pct", "data-k": k }, share), label: node("text", svg, { class: "value" }, `${value.toLocaleString("ja-JP")}${unit}`),
};
});
svg.append(...slices.map((s) => s.pct), ...slices.map((s) => s.label)); // ラベルは、どの扇より上に描く
chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す
// 幅から大きさを決める。r は輪の真ん中の半径で、この弧の長さがそのまま棒の長さになる
let g;
const layout = () => {
const w = svg.clientWidth;
const x = w < 560 ? 64 : 136; // 項目名の列
const r = Math.min(180, (w - x - (w < 560 ? 64 : 88)) / (2 * Math.PI * Math.max(...slices.map((s) => s.share))));
const [thick, cy] = [r * (w < 560 ? 0.5 : 0.4), r * (w < 560 ? 1.25 : 1.2) + 8]; // 狭い画面では輪を太くして、構成比を収める
const pitch = Math.min(64, (2 * r + thick) / slices.length);
g = { x, r, thick, bar: pitch * 0.56, cx: x + (w - x) / 2, cy };
svg.setAttribute("viewBox", `0 0 ${w} ${cy * 2 + 32}`);
Object.assign(total.style, { left: `${g.cx}px`, top: `${cy}px` });
slices.forEach((s, i) => {
Object.assign(s, { length: s.share * Math.PI * 2 * r, row: cy + pitch * (i - (slices.length - 1) / 2) });
for (const [el, y] of [[s.swatch, s.row - 7], [s.name, s.row]]) el.setAttribute("y", y);
});
// 棒グラフの目盛り(100 ごと。棒のときだけ出す)
axis.replaceChildren();
const [top, bottom] = [slices[0].row - pitch / 2, slices[slices.length - 1].row + pitch / 2];
const last = Math.floor(Math.max(...slices.map((s) => s.value)) / 100) * 100;
for (let v = 0; v <= last; v += 100) {
const tx = Math.round(x + (v / sum) * Math.PI * 2 * r) + 0.5;
node("line", axis, { x1: tx, x2: tx, y1: top - 4, y2: bottom + 4 });
node("text", axis, { x: tx, y: bottom + 24 }, `${v.toLocaleString("ja-JP")}${v === last ? unit : ""}`);
}
draw();
};
// 扇を「真ん中の点・向き・曲がり(1 / 半径)・長さ・太さ」の帯として描く。長さは変えないので、弧がほどけて同じ長さの棒になる
const draw = () => {
let avg = 0;
for (const s of slices) {
avg += s.p / slices.length;
const [eb, et] = [bend(clamp01(s.p / 0.75)), travel(clamp01((s.p - 0.1) / 0.9))];
const [k, psi, thick] = [(1 - eb) / g.r, lerp(s.mid, s.flat, eb), lerp(g.thick, g.bar, et)]; // 曲がり・向き・太さ
const [mx, my] = [lerp(g.cx + g.r * Math.sin(s.mid), g.x + s.length / 2, et), lerp(g.cy - g.r * Math.cos(s.mid), s.row, et)];
const at = (sigma, off) => { // 真ん中の線を σ だけ進み、法線の向きへ off ずらした点(曲がりが 0 なら直線)
const h = psi + k * sigma;
const [dx, dy] = k < 1e-6 ? [sigma * Math.cos(psi), sigma * Math.sin(psi)] : [(Math.sin(h) - Math.sin(psi)) / k, (Math.cos(psi) - Math.cos(h)) / k];
return [mx + dx - Math.sin(h) * off, my + dy + Math.cos(h) * off];
};
const edge = (off) => Array.from({ length: 41 }, (_, n) => at((n / 40 - 0.5) * s.length, off));
const pts = [...edge(-thick / 2), ...edge(thick / 2).reverse()];
s.path.setAttribute("d", `M${pts.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join("L")}Z`);
// 構成比は帯の真ん中に付いて動き、始めに消える。金額は棒の右の端に付き、終わりに出る
const [[px, py], [vx, vy]] = [at(0, 0), at((Math.cos(s.flat) > 0 ? 0.5 : -0.5) * s.length, 0)];
set(s.pct, { x: px, y: py, opacity: clamp01(1 - s.p / 0.25) });
set(s.label, { x: vx + 14, y: vy, opacity: clamp01((s.p - 0.7) / 0.3) });
}
axis.style.opacity = clamp01((avg - 0.6) / 0.4);
total.style.opacity = clamp01(1 - avg / 0.2);
};
new ResizeObserver(layout).observe(svg);
// 押すと、扇が 1 枚ずつほどけて棒へ(逆も同じ道をたどる)。p は時間どおりに進め、ease は描くときにかける
let tween;
buttons.forEach((button, to) => button.addEventListener("click", () => {
buttons.forEach((b, j) => b.setAttribute("aria-pressed", String(j === to)));
chart.dataset.mode = to ? "bars" : "pie";
tween?.kill(); // 続けて押したら、今の形から向かい直す
if (reduceMotion) return (slices.forEach((s) => (s.p = to)), draw());
tween = gsap.to(slices, { p: to, duration: (n, s) => Math.abs(to - s.p) * 1.1, ease: "none", stagger: 0.12, delay: 0.05, onUpdate: draw });
}));
});