デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- 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 で決めています。下げるほど重ねた流れが際立ちますが、そのぶん全体のどこにある流れなのかは見えにくくなります。
コード
<!-- 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>
.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; }
}
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);
});