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

グラフ

流入元から行き先へ、人数ぶんの太さの流れが順に伸びるサンキー図

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

左に並んだ検索・SNS・広告・メールの棒から、人数に比例した太さの流れが、流入元ごとに少しずつ遅れて右へ伸びていきます。右端に着いた流れは、行き先の棒にその流入元の色の区切りとして積まれ、行き先の人数が数え上がっていきます。どこから来た人がどのページへ向かったのかを、太さと色の重なりでたどれるグラフです。

流れは、上下の縁を3次ベジェ曲線で描いた面です。上下の曲線は制御点の x をそろえてあるので、どちらも同じ割合のところで分けて前半どうしをつなげば、先端が縦にまっすぐ切れた形になります。伸びる途中を clipPath で切り抜く方法もありますが、こちらは id を使わずに済むので、同じページにグラフを並べても切り抜きが混ざる心配がありません。伸びる時間は1.3秒の power2.inOut、流入元ごとのずれは0.42秒にしました。

積む順番にも決まりがあります。流入元の棒の中では行き先の順に、行き先の棒の中では流入元の順に流れを積んでいます。こうそろえておくと流れどうしの交差が減り、行き先の棒の区切りも、どの棒で見ても同じ色の並びになります。

流れにカーソルを重ねると、その流れだけが濃くなり、ほかは不透明度0.12まで薄くなります。ツールチップはポインタの右上に付いてきて、「検索 → 商品一覧」「1,240人」のように経路と人数を表示します。キーボードでは流れを順に Tab でたどれますが、そのときはポインタの位置がないので、フォーカスした流れの中ほどにツールチップを置くことにしました。

人数はページの中に表として書いてあり、スクリプトはそれを読んで流れを組み立ててから表を隠します。流れの path にはそれぞれ経路と人数を aria-label で付けたので、スクリーンリーダーでも流れごとに読み上げられます。ほかの流れをどこまで薄くするかは --flow-dim で決めています。下げるほど重ねた流れが際立ちますが、そのぶん全体のどこにある流れなのかは見えにくくなります。

コード

HTML
<!-- data-unit は人数の単位。値はこの表に書き、JS が表から流れを組む(組んだあと表は隠し、流れの一本ずつに同じ値を読ませる) -->
<figure class="sankey" data-unit="人">
  <figcaption class="sankey__caption">
    <span class="sankey__eyebrow">Traffic flow</span>
    <span class="sankey__title">サイトへの流入と行き先</span>
    <span class="sankey__note">9月・単位:人</span>
  </figcaption>

  <div class="sankey__chart">
    <!-- 流入元・行き先の並びは、表に初めて出てきた順になる。流入元の色は、その順に style.css の --source-1〜4 -->
    <table class="sankey__data">
      <caption>流入元ごとの行き先の人数</caption>
      <thead>
        <tr><th scope="col">流入元</th><th scope="col">行き先</th><th scope="col">人数</th></tr>
      </thead>
      <tbody>
        <tr><td>検索</td><td>トップ</td><td>1,860</td></tr>
        <tr><td>検索</td><td>商品一覧</td><td>1,240</td></tr>
        <tr><td>検索</td><td>ブログ</td><td>980</td></tr>
        <tr><td>検索</td><td>お問い合わせ</td><td>210</td></tr>
        <tr><td>SNS</td><td>トップ</td><td>640</td></tr>
        <tr><td>SNS</td><td>商品一覧</td><td>420</td></tr>
        <tr><td>SNS</td><td>ブログ</td><td>1,120</td></tr>
        <tr><td>広告</td><td>トップ</td><td>380</td></tr>
        <tr><td>広告</td><td>商品一覧</td><td>960</td></tr>
        <tr><td>広告</td><td>お問い合わせ</td><td>90</td></tr>
        <tr><td>メール</td><td>トップ</td><td>260</td></tr>
        <tr><td>メール</td><td>商品一覧</td><td>540</td></tr>
        <tr><td>メール</td><td>ブログ</td><td>120</td></tr>
        <tr><td>メール</td><td>お問い合わせ</td><td>140</td></tr>
      </tbody>
    </table>
    <!-- 重ねた流れの経路と人数。流れそのものに同じ値を読ませるので、ツールチップは読み上げから外す -->
    <div class="sankey__tip" aria-hidden="true"><span></span><b></b></div>
  </div>
</figure>

CSS
.sankey {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  /* 流入元の色。明るさの差で見分けられる順に並べる */
  --source-1: #375f79;
  --source-2: #ca6359;
  --source-3: #bd894c;
  --source-4: #7dbe7f;
  --flow: 0.5; /* 流れの濃さ */
  --flow-hot: 0.86; /* 重ねた流れの濃さ */
  --flow-dim: 0.12; /* 重ねている間の、ほかの流れの濃さ */
  margin: 0;
  padding: 64px clamp(16px, 5vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.sankey__caption { display: grid; gap: 12px; }
.sankey__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.sankey__title { font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.sankey__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }

.sankey__chart { position: relative; margin-top: 40px; }
.sankey__svg { display: block; width: 100%; overflow: visible; }

/* 流れと棒の色。data-source は、表に初めて出てきた流入元の順の番号 */
.sankey [data-source="0"] { fill: var(--source-1); }
.sankey [data-source="1"] { fill: var(--source-2); }
.sankey [data-source="2"] { fill: var(--source-3); }
.sankey [data-source="3"] { fill: var(--source-4); }

.sankey__flow { fill-opacity: 0; outline: none; }
.sankey__flow:focus-visible { stroke: var(--ink); stroke-width: 2; }
.sankey__name { fill: var(--mute-small); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }
.sankey__count { fill: var(--ink); font-size: 20px; font-weight: 800; }
.sankey__label { pointer-events: none; }

/* ツールチップは明暗を反転する */
.sankey__tip {
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  gap: 6px;
  padding: 12px 16px;
  border-radius: 4px;
  background: var(--ink);
  color: #ffffff;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
}
.sankey__tip span { color: #c8c7c3; font-size: 13px; font-weight: 600; letter-spacing: 0.04em; }
.sankey__tip b { font-size: 20px; font-weight: 800; line-height: 1; }

/* 表は JS が流れを組んだあと hidden にする(JS が動かないときは、表のまま読める) */
.sankey__data { border-collapse: collapse; font-size: 15px; }
.sankey__data caption { margin-bottom: 8px; text-align: left; color: var(--mute); }
.sankey__data th,
.sankey__data td { padding: 6px 24px 6px 0; border-bottom: 1px solid rgb(38 38 38 / 0.12); text-align: left; }

/* 狭い画面では、名前と人数を小さくする(左右の名前の列の幅は script.js が画面の幅で決める) */
@media (max-width: 600px) {
  .sankey__name { font-size: 13px; }
  .sankey__count { font-size: 16px; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = (a, b, p) => a + (b - a) * p;
const set = (el, attrs) => Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value));
const make = (tag, attrs, parent, text = "") => {
  const el = parent.appendChild(document.createElementNS("http://www.w3.org/2000/svg", tag));
  return set(el, attrs), (el.textContent = text), el;
};

document.querySelectorAll(".sankey").forEach((chart) => {
  const box = chart.querySelector(".sankey__chart");
  const table = chart.querySelector(".sankey__data");
  const tip = chart.querySelector(".sankey__tip");
  const css = (name) => Number(getComputedStyle(chart).getPropertyValue(name));
  const format = (n) => `${Math.round(n).toLocaleString("ja-JP")}${chart.dataset.unit}`;
  // 表の行から流れを作る。流入元と行き先は、表に初めて出てきた順に並べる
  const rows = [...table.tBodies[0].rows].map((row) => [...row.cells].map((cell) => cell.textContent.trim()));
  const sources = [...new Set(rows.map((r) => r[0]))];
  const targets = [...new Set(rows.map((r) => r[1]))];
  const links = rows.map(([from, to, value]) => ({ source: sources.indexOf(from), target: targets.indexOf(to), value: Number(value.replace(/\D/g, "")), name: `${from} → ${to}`, grow: 0, seg: 0, hot: 0 }));
  const total = (key, i) => links.filter((l) => l[key] === i).reduce((sum, l) => sum + l.value, 0);

  const svg = make("svg", { class: "sankey__svg", role: "group", "aria-label": table.caption.textContent }, box);
  const labels = make("g", { class: "sankey__label", "aria-hidden": "true" }, svg);
  const label = (name, value, g = make("g", {}, labels)) => (make("text", { class: "sankey__name" }, g, name), { g, count: make("text", { class: "sankey__count", dy: "1.6em" }, g, format(value)) });
  // 流れは流入元の順に描く(あとの流入元が手前)。一本ずつフォーカスでき、経路と人数を読み上げる
  links.forEach((l) => {
    l.path = make("path", { class: "sankey__flow", "data-source": l.source, tabindex: 0, role: "img", "aria-label": `${l.name} ${format(l.value)}` }, svg);
    l.piece = make("rect", { "data-source": l.source, opacity: 0 }, svg); // 行き先の棒に積む、この流れの分
  });
  const lefts = sources.map((name, i) => ({ bar: make("rect", { "data-source": i }, svg), value: total("source", i), h: 0, ...label(name, total("source", i)) }));
  const rights = targets.map((name, j) => ({ value: total("target", j), a: 0, ...label(name, 0) }));
  table.hidden = true;
  // 画面の幅から、棒の位置と流れの上下の位置を決める。流入元の中は行き先の順、行き先の中は流入元の順に積む
  let x0, x1, xm;
  const layout = () => {
    const w = box.clientWidth, narrow = w < 600, h = narrow ? 360 : 440, gap = narrow ? 16 : 24, side = narrow ? 92 : 168, bar = narrow ? 12 : 16;
    set(svg, { viewBox: `0 0 ${w} ${h}`, height: h });
    const k = (h - gap * (Math.max(sources.length, targets.length) - 1)) / links.reduce((sum, l) => sum + l.value, 0);
    [x0, x1, xm] = [side + bar, w - side - bar, w / 2];
    const stack = (nodes, tx) => nodes.reduce((y, n) => {
      Object.assign(n, { y, size: n.value * k, next: y });
      n.g.setAttribute("transform", `translate(${tx} ${Math.round(y + n.size / 2 - 4)})`);
      return y + n.size + gap;
    }, 0);
    stack(lefts, 0);
    stack(rights, x1 + bar + 12);
    links.forEach((l) => { l.w = l.value * k; l.y0 = lefts[l.source].next; lefts[l.source].next += l.w; });
    [...links].sort((a, b) => a.target - b.target || a.source - b.source).forEach((l) => { l.y1 = rights[l.target].next; rights[l.target].next += l.w; });
    links.forEach((l) => set(l.piece, { x: x1, y: l.y1, width: bar, height: l.w }));
    lefts.forEach((n) => set(n.bar, { x: side, width: bar }));
  };
  // 上下の縁が同じ x の制御点を持つ 3 次ベジェなので、s で分けた前半(de Casteljau)も縦に切った形になる
  const split = (l, s, dy = 0) => {
    const mix = (a, b) => [lerp(a[0], b[0], s), lerp(a[1], b[1], s)];
    const p = [[x0, l.y0 + dy], [xm, l.y0 + dy], [xm, l.y1 + dy], [x1, l.y1 + dy]];
    const a = mix(p[0], p[1]), b = mix(p[1], p[2]), ab = mix(a, b);
    return [p[0], a, ab, mix(ab, mix(b, mix(p[2], p[3])))];
  };
  const band = (l) => {
    if (l.grow <= 0) return "";
    const [q0, q1, q2, q3] = split(l, l.grow), [r0, r1, r2, r3] = split(l, l.grow, l.w);
    return `M${q0}C${q1} ${q2} ${q3}L${r3}C${r2} ${r1} ${r0}Z`;
  };
  const draw = () => {
    const hottest = links.reduce((best, l) => (l.hot > best.hot ? l : best));
    const rest = lerp(css("--flow"), css("--flow-dim"), hottest.hot); // 重ねている間は、ほかの流れを薄くする
    links.forEach((l) => { set(l.path, { d: band(l) }); l.path.style.fillOpacity = lerp(rest, css("--flow-hot"), l.hot); set(l.piece, { opacity: l.seg }); });
    lefts.forEach((n) => { set(n.bar, { y: n.y + (n.size * (1 - n.h)) / 2, height: n.size * n.h }); n.g.style.opacity = n.h; });
    rights.forEach((n, j) => { n.g.style.opacity = n.a; n.count.textContent = format(links.reduce((sum, l) => sum + (l.target === j ? l.value * l.seg : 0), 0)); });
    tip.style.opacity = hottest.hot;
    [tip.firstElementChild.textContent, tip.lastElementChild.textContent] = [hottest.name, format(hottest.value)];
  };

  // 流入元の棒を伸ばし、流れを流入元ごとに順に左から右へ伸ばす。着いた流れの分が、行き先の棒に積まれて人数が数え上がる
  const tl = gsap.timeline({ paused: true, onUpdate: draw }).to(lefts, { h: 1, duration: 0.5, ease: "power3.out", stagger: 0.08 }, 0);
  const startOf = (l) => 0.4 + l.source * 0.42 + l.target * 0.06;
  links.forEach((l) => tl.to(l, { grow: 1, duration: 1.3, ease: "power2.inOut" }, startOf(l)).to(l, { seg: 1, duration: 0.3, ease: "power1.out" }, startOf(l) + 1.15));
  rights.forEach((n, j) => tl.to(n, { a: 1, duration: 0.35, ease: "power1.out" }, Math.min(...links.filter((l) => l.target === j).map(startOf)) + 1.2));

  // 重ねた(フォーカスした)流れだけを濃くし、ツールチップを付ける。マウスはポインタの右上、キーボードは流れの中ほど
  const place = (x, y) => (tip.style.transform = `translate(${Math.round(Math.min(box.clientWidth - tip.offsetWidth, x + 18))}px, ${Math.round(Math.max(0, y - tip.offsetHeight - 18))}px)`);
  const follow = (e) => { const r = box.getBoundingClientRect(); place(e.clientX - r.left, e.clientY - r.top); };
  const heat = (l, hot) => { l.tween?.kill(); l.tween = gsap.to(l, { hot, duration: reduceMotion ? 0 : 0.2, ease: "power2.out", onUpdate: draw }); };
  links.forEach((l) => {
    l.path.addEventListener("pointerenter", (e) => { follow(e); heat(l, 1); });
    l.path.addEventListener("pointermove", follow);
    l.path.addEventListener("focus", () => { const mid = split(l, 0.5, l.w / 2)[3]; place(mid[0], mid[1]); heat(l, 1); });
    for (const type of ["pointerleave", "blur"]) l.path.addEventListener(type, () => heat(l, 0));
  });

  layout();
  new ResizeObserver(() => { layout(); draw(); }).observe(box);
  // 動きを減らす設定では、出そろった状態で見せる。ほかは画面に入ったら 1 回だけ伸ばす
  const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (io.disconnect(), tl.play()), { rootMargin: "0px 0px -20%" });
  if (reduceMotion) tl.progress(1);
  else io.observe(box);
});

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

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