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

折れ線グラフが描かれ、点と値が現れる

デモ

仕様

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

しくみと調整

折れ線は、点を通るなめらかな曲線を DrawSVGPlugin で始点から描いていきます。線の下の面は、線の先端がいま届いている x の位置までだけ見せるので、面も線と一緒に左から広がります。各週の点は線の先端が届いた瞬間にはじけるように現れ、その上に値が浮かびます。最後の週は塗った点と明るい札にして、いちばん伝えたい数字を目立たせます。

線を描くのは 2 秒、イージングは power1.inOut です。点がはじける時刻は、このイージングを逆にたどり、線の先端がその点までの長さに届く時刻として求めています。点を等間隔の時刻で出すと、ゆっくり描く始まりと終わりで、点が線の先端より先に出てしまいます。点は 0.45 秒で一瞬大きくなってから落ち着き、値は 0.08 秒遅れて 10px 下から浮かびます。

グラフは、描いた線だけでは読み上げに何も届きません。週と値は表としてページに書き、スクリプトはその表を読んで線を描きます。描いたあとの表は見えなくするだけにして、display: none では消しません。消すと、読み上げからも値が消えてしまいます。値を持つ場所が表だけになるので、あとから数字を直しても、線と値がずれません。

描くのは、グラフが画面に入った 1 回だけにします。スクロールで出入りするたびに描き直すと、値を読むひまがありません。動きを減らす設定では、描き終えた状態で見せます。スクリプトが動かないときは表がそのまま見えるので、値は読めます。

つまみは、描く時間とイージング、点の大きさ、目盛りの上端と間隔です。目盛りの上端を実際の最大値ぴったりにすると、最後の点と札が天井に貼り付くので、少し余裕を取ります。利用者数や売上の推移のように、伸びていく流れそのものを見せたい節に向いています。

コード

HTML
<!-- 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>

CSS
.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;
}

JavaScript
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);
});

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

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