デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- 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で読み、多角形と点の色に使うので、色を変えたいときは表のスタイルを直すだけで済みます。値が伸びるあいだ、表のセルの数字も同じ位置から数え上げ直すので、図と数字がずれません。
頂点の伸びる速さや遅らせ方、目盛りの段階は、どれもスクリプトの先頭で変えられます。画面に入ったときに一度だけ開き、動きを減らす設定では最初から開ききった状態で置きます。製品やプランの比較、アンケートの項目別スコアのように、複数の指標を並べて見せたい場面に向いています。
コード
<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>
.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;
}
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);
});