デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
折れ線は、点を通るなめらかな曲線を DrawSVGPlugin で始点から描いていきます。線の下の面は、線の先端がいま届いている x の位置までだけ見せるので、面も線と一緒に左から広がります。各週の点は線の先端が届いた瞬間にはじけるように現れ、その上に値が浮かびます。最後の週は塗った点と明るい札にして、いちばん伝えたい数字を目立たせます。
線を描くのは 2 秒、イージングは power1.inOut です。点がはじける時刻は、このイージングを逆にたどり、線の先端がその点までの長さに届く時刻として求めています。点を等間隔の時刻で出すと、ゆっくり描く始まりと終わりで、点が線の先端より先に出てしまいます。点は 0.45 秒で一瞬大きくなってから落ち着き、値は 0.08 秒遅れて 10px 下から浮かびます。
グラフは、描いた線だけでは読み上げに何も届きません。週と値は表としてページに書き、スクリプトはその表を読んで線を描きます。描いたあとの表は見えなくするだけにして、display: none では消しません。消すと、読み上げからも値が消えてしまいます。値を持つ場所が表だけになるので、あとから数字を直しても、線と値がずれません。
描くのは、グラフが画面に入った 1 回だけにします。スクロールで出入りするたびに描き直すと、値を読むひまがありません。動きを減らす設定では、描き終えた状態で見せます。スクリプトが動かないときは表がそのまま見えるので、値は読めます。
つまみは、描く時間とイージング、点の大きさ、目盛りの上端と間隔です。目盛りの上端を実際の最大値ぴったりにすると、最後の点と札が天井に貼り付くので、少し余裕を取ります。利用者数や売上の推移のように、伸びていく流れそのものを見せたい節に向いています。
コード
<!-- data-max は目盛りの上端、data-step は目盛りの間隔 -->
<figure class="line-chart" data-max="20000" data-step="5000">
<figcaption class="line-chart__caption">
<span class="line-chart__title">週ごとのアクティブユーザー</span>
<span class="line-chart__note">直近10週・単位:人</span>
</figcaption>
<!-- 値はこの表が正。JS が表から折れ線を描き、表は読み上げ用に残す(スクリプトが無いときは表のまま見える) -->
<table class="line-chart__table">
<thead>
<tr><th scope="col">週</th><th scope="col">アクティブユーザー(人)</th></tr>
</thead>
<tbody>
<tr><th scope="row">7月6日</th><td>6,200</td></tr>
<tr><th scope="row">7月13日</th><td>7,800</td></tr>
<tr><th scope="row">7月20日</th><td>7,100</td></tr>
<tr><th scope="row">7月27日</th><td>9,400</td></tr>
<tr><th scope="row">8月3日</th><td>11,200</td></tr>
<tr><th scope="row">8月10日</th><td>10,300</td></tr>
<tr><th scope="row">8月17日</th><td>12,900</td></tr>
<tr><th scope="row">8月24日</th><td>14,600</td></tr>
<tr><th scope="row">8月31日</th><td>13,800</td></tr>
<tr><th scope="row">9月7日</th><td>18,600</td></tr>
</tbody>
</table>
</figure>
.line-chart {
--bg: #111111;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--grid: rgb(238 236 230 / 0.14);
--line: #98b1c4; /* 線・点・面の色 */
--line-width: 4;
--tag-bg: #eeece6; /* 最後の週の値の札 */
--tag-ink: #111111;
margin: 0;
padding: 40px clamp(24px, 5vw, 64px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.line-chart__caption { display: grid; gap: 8px; margin-bottom: 32px; }
.line-chart__title { font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; word-break: auto-phrase; }
.line-chart__note { font-size: 16px; letter-spacing: 0.04em; color: var(--mute); }
/* 狭い画面では、字が小さくなりすぎないよう、グラフを横にスクロールさせる */
.line-chart__frame { overflow-x: auto; }
.line-chart__plot { display: block; width: 100%; min-width: 560px; height: auto; }
.line-chart__grid { stroke: var(--grid); stroke-width: 1; stroke-dasharray: 4 6; }
.line-chart__axis { stroke: var(--grid); stroke-width: 2; }
.line-chart__tick,
.line-chart__week { fill: var(--mute-small); font-size: 13px; }
.line-chart__tick { text-anchor: end; }
.line-chart__week { text-anchor: middle; letter-spacing: 0.04em; }
/* 線の下の面は、上から下へ薄くなるグラデーション */
.line-chart__fade stop { stop-color: var(--line); }
.line-chart__fade stop:first-child { stop-opacity: 0.32; }
.line-chart__fade stop:last-child { stop-opacity: 0; }
.line-chart__line {
fill: none;
stroke: var(--line);
stroke-width: var(--line-width);
stroke-linecap: round;
stroke-linejoin: round;
}
.line-chart__dot { fill: var(--bg); stroke: var(--line); stroke-width: 3; }
.line-chart__dot--last { fill: var(--line); }
.line-chart__value text { fill: var(--ink); font-size: 14px; font-weight: 600; text-anchor: middle; }
.line-chart__tag rect { fill: var(--tag-bg); }
.line-chart__tag text { fill: var(--tag-ink); font-size: 15px; font-weight: 700; text-anchor: middle; }
/* スクリプトが無いときに見える表 */
.line-chart__table { border-collapse: collapse; font-size: 14px; }
.line-chart__table th,
.line-chart__table td { padding: 8px 24px 8px 0; border-bottom: 1px solid var(--grid); text-align: left; }
.line-chart__table td { text-align: right; }
/* 描いたあとの表は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.line-chart.is-drawn .line-chart__table {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const DRAW = 2; // 線を描く時間(秒)
const EASE = "power1.inOut";
const BOX = { width: 720, height: 380, left: 56, right: 64, top: 56, bottom: 44 }; // SVG の座標での大きさと余白
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// power1.inOut の逆関数。線の先端が、全長の割合 r の所に届く時刻(0〜1)
const reach = (r) => (r < 0.5 ? Math.sqrt(r / 2) : 1 - Math.sqrt((1 - r) / 2));
function el(parent, name, attrs = {}, text) {
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
if (text !== undefined) node.textContent = text;
parent.append(node);
return node;
}
// 点を通るなめらかな線。前後の点から 3 次ベジェの制御点を決める(Catmull-Rom)
function curve(points) {
return points.slice(1).map((p2, i) => {
const p1 = points[i];
const p0 = points[i - 1] ?? p1;
const p3 = points[i + 2] ?? p2;
return `C${p1[0] + (p2[0] - p0[0]) / 6},${p1[1] + (p2[1] - p0[1]) / 6} ${p2[0] - (p3[0] - p1[0]) / 6},${p2[1] - (p3[1] - p1[1]) / 6} ${p2}`;
});
}
document.querySelectorAll(".line-chart").forEach((chart, n) => {
// 週と値は表から読む。値を持つ場所が 1 つなので、表を直せば線も直る
const rows = [...chart.querySelectorAll("tbody tr")];
const values = rows.map((row) => Number(row.cells[1].textContent.replace(/,/g, "")));
const { width, height, left, right, top, bottom } = BOX;
const max = Number(chart.dataset.max);
const x = (i) => left + ((width - left - right) * i) / (values.length - 1);
const y = (v) => height - bottom - ((height - top - bottom) * v) / max;
const frame = chart.appendChild(Object.assign(document.createElement("div"), { className: "line-chart__frame" }));
const svg = el(frame, "svg", { class: "line-chart__plot", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
const defs = el(svg, "defs");
const fade = el(defs, "linearGradient", { id: `line-chart-fade-${n}`, class: "line-chart__fade", x2: 0, y2: 1 });
for (const offset of [0, 1]) el(fade, "stop", { offset });
const clip = el(el(defs, "clipPath", { id: `line-chart-clip-${n}` }), "rect", { width: 0, height });
for (let v = 0; v <= max; v += Number(chart.dataset.step)) {
el(svg, "line", { class: v ? "line-chart__grid" : "line-chart__axis", x1: left, x2: width - right, y1: y(v), y2: y(v) });
el(svg, "text", { class: "line-chart__tick", x: left - 12, y: y(v) + 4 }, v.toLocaleString("ja-JP"));
}
rows.forEach((row, i) => el(svg, "text", { class: "line-chart__week", x: x(i), y: height - 14 }, row.cells[0].textContent));
const points = values.map((v, i) => [x(i), y(v)]);
const parts = curve(points);
const start = `M${points[0]}`;
el(svg, "path", { d: `${start}${parts.join("")}V${y(0)}H${x(0)}Z`, fill: `url(#line-chart-fade-${n})`, "clip-path": `url(#line-chart-clip-${n})` });
const line = el(svg, "path", { class: "line-chart__line" });
// 始点から各点までの線の長さ(点が出る時刻を、線の先端が届く時刻に合わせるため)
const lengths = points.map((_, i) => {
line.setAttribute("d", start + parts.slice(0, i).join(""));
return line.getTotalLength();
});
line.setAttribute("d", start + parts.join(""));
const total = line.getTotalLength();
const tl = gsap.timeline({ paused: true }).fromTo(line, { drawSVG: "0%" }, {
drawSVG: "100%", duration: DRAW, ease: EASE,
// 線の下の面は、描けた先端の x までだけ見せる
onUpdate() { clip.setAttribute("width", line.getPointAtLength(this.ratio * total).x); },
});
points.forEach(([px, py], i) => {
const last = i === values.length - 1; // 最後の週だけ、塗った点と明るい札にする
const dot = el(svg, "circle", { class: `line-chart__dot${last ? " line-chart__dot--last" : ""}`, cx: px, cy: py, r: 0 });
const label = el(svg, "g", { class: last ? "line-chart__tag" : "line-chart__value" });
const text = el(label, "text", { x: px, y: py - (last ? 41 : 18) }, `${values[i].toLocaleString("ja-JP")}${last ? "人" : ""}`);
if (last) {
const w = text.getComputedTextLength() + 32;
el(label, "rect", { x: px - w / 2, y: py - 62, width: w, height: 32, rx: 16 });
label.append(text); // 札の地の上に文字を重ねる
}
const at = DRAW * reach(lengths[i] / total); // 線の先端がこの点に届く時刻
tl.fromTo(dot, { attr: { r: 0 } }, { attr: { r: last ? 8 : 6 }, duration: 0.45, ease: "back.out(3)" }, at)
.fromTo(label, { autoAlpha: 0, y: 10 }, { autoAlpha: 1, y: 0, duration: 0.4, ease: "power2.out" }, at + 0.08);
});
chart.classList.add("is-drawn");
// 動きを減らす設定では、描き終えた状態で見せる
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);
});