01太さが流量を表すサンキー・リボン図
リボン一本の太さをその区間が運ぶ値の大きさに合わせて計算し、太いリボンほど多くの量が流れていることを一目で示す。
使いどころ予算配分・流入経路レポート
SANKEY_DRAW_DURATION_MSRIBBON_STAGGER_MSbuildLayout

<main class="stage">
<div class="skr is-demo" id="skrRoot">
<canvas class="skr__canvas" id="skrCanvas" width="480" height="300"></canvas>
<p class="skr__caption">リボンの太さをその区間が運ぶ値の大きさに合わせて計算し、太いリボンほど多くの量が流れていることが一目でわかる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.skr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.skr__canvas {
display: block;
width: 100%;
height: 100%;
}
.skr__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.skr__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.skr { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.skr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.skr__canvas {
display: block;
width: 100%;
height: 100%;
}
.skr__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.skr__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.skr {
cursor: default;
}
}(function () {
// 기법: 좌우 노드의 총합을 공통 SCALE 하나로 픽셀에 매핑해 리본 두께가 실제 값 비율을
// 그대로 나타내게 하고(진짜 산키 보존), 리본마다 시작 지연을 두고 사각 클립을 왼쪽에서
// 오른쪽으로 넓혀가며 그 안에 리본 전체 도형을 그려 "그어지는" 진행을 만든다.
var root = document.getElementById('skrRoot');
var canvas = document.getElementById('skrCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(425011);
var LEFT = ['A', 'B', 'C'];
var RIGHT = ['P', 'Q'];
var FLOW = [
[18 + rand() * 40, 0],
[10 + rand() * 30, 14 + rand() * 36],
[0, 22 + rand() * 44]
];
// 리본이 왼쪽에서 오른쪽으로 그어지는 데 걸리는 시간(ms). 변형은 900 → 1800 으로 바뀐다.
var SANKEY_DRAW_DURATION_MS = 900;
var RIBBON_STAGGER_MS = 90;
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); }
function buildLayout(cw, ch) {
var nodeW = Math.max(8, cw * 0.045);
var marginX = cw * 0.12;
var marginY = ch * 0.14;
var nodeGap = ch * 0.05;
var leftX = marginX;
var rightX = cw - marginX - nodeW;
var availH = ch - marginY * 2;
var leftTotal = [0, 0, 0];
var rightTotal = [0, 0];
for (var i = 0; i < LEFT.length; i += 1) {
for (var j = 0; j < RIGHT.length; j += 1) {
leftTotal[i] += FLOW[i][j];
rightTotal[j] += FLOW[i][j];
}
}
var grandTotal = leftTotal[0] + leftTotal[1] + leftTotal[2];
var leftGaps = nodeGap * (LEFT.length - 1);
var rightGaps = nodeGap * (RIGHT.length - 1);
var usableH = availH - Math.max(leftGaps, rightGaps);
var SCALE = usableH / grandTotal;
var leftH = [leftTotal[0] * SCALE, leftTotal[1] * SCALE, leftTotal[2] * SCALE];
var rightH = [rightTotal[0] * SCALE, rightTotal[1] * SCALE];
var totalLeftH = leftH[0] + leftH[1] + leftH[2] + leftGaps;
var totalRightH = rightH[0] + rightH[1] + rightGaps;
var leftStartY = marginY + (availH - totalLeftH) / 2;
var rightStartY = marginY + (availH - totalRightH) / 2;
var leftY = [];
var cursorL = leftStartY;
for (i = 0; i < LEFT.length; i += 1) { leftY.push(cursorL); cursorL += leftH[i] + nodeGap; }
var rightY = [];
var cursorR = rightStartY;
for (j = 0; j < RIGHT.length; j += 1) { rightY.push(cursorR); cursorR += rightH[j] + nodeGap; }
var leftCursor = leftY.slice();
var rightCursor = rightY.slice();
var ribbons = [];
var idx = 0;
for (i = 0; i < LEFT.length; i += 1) {
for (j = 0; j < RIGHT.length; j += 1) {
var v = FLOW[i][j];
if (v <= 0) continue;
var thick = v * SCALE;
var leftTop = leftCursor[i];
var leftBot = leftTop + thick;
leftCursor[i] = leftBot;
var rightTop = rightCursor[j];
var rightBot = rightTop + thick;
rightCursor[j] = rightBot;
ribbons.push({
j: j, leftTop: leftTop, leftBot: leftBot, rightTop: rightTop, rightBot: rightBot,
delay: idx * RIBBON_STAGGER_MS
});
idx += 1;
}
}
return {
nodeW: nodeW, leftX: leftX, rightX: rightX,
leftY: leftY, rightY: rightY, leftH: leftH, rightH: rightH,
ribbons: ribbons
};
}
var COLOR_P = '47,109,246';
var COLOR_Q = '194,54,26';
function drawRibbon(r, progress, fadeAlpha) {
var startX = layout.leftX + layout.nodeW;
var endX = layout.rightX;
var midX = (startX + endX) / 2;
var clipRight = lerp(startX, endX, progress);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, clipRight, ch);
ctx.clip();
ctx.beginPath();
ctx.moveTo(startX, r.leftTop);
ctx.bezierCurveTo(midX, r.leftTop, midX, r.rightTop, endX, r.rightTop);
ctx.lineTo(endX, r.rightBot);
ctx.bezierCurveTo(midX, r.rightBot, midX, r.leftBot, startX, r.leftBot);
ctx.closePath();
var rgb = r.j === 0 ? COLOR_P : COLOR_Q;
ctx.fillStyle = 'rgba(' + rgb + ',' + (0.62 * fadeAlpha) + ')';
ctx.fill();
ctx.restore();
}
function drawNodes() {
ctx.font = Math.max(9, cw * 0.024) + 'px sans-serif';
ctx.textBaseline = 'middle';
var i, j;
for (i = 0; i < LEFT.length; i += 1) {
ctx.fillStyle = '#fff7e6';
ctx.fillRect(layout.leftX, layout.leftY[i], layout.nodeW, layout.leftH[i]);
ctx.fillStyle = 'rgba(255,247,230,.92)';
ctx.textAlign = 'right';
ctx.fillText(LEFT[i], layout.leftX - 6, layout.leftY[i] + layout.leftH[i] / 2);
}
for (j = 0; j < RIGHT.length; j += 1) {
ctx.fillStyle = '#fff7e6';
ctx.fillRect(layout.rightX, layout.rightY[j], layout.nodeW, layout.rightH[j]);
ctx.fillStyle = 'rgba(255,247,230,.92)';
ctx.textAlign = 'left';
ctx.fillText(RIGHT[j], layout.rightX + layout.nodeW + 6, layout.rightY[j] + layout.rightH[j] / 2);
}
}
var LOOP_MS = 2000;
var T0 = 200;
var cw = 480, ch = 300;
var layout = buildLayout(cw, ch);
function draw(nowMs) {
var phase = reduce ? 1500 : (nowMs % LOOP_MS);
var t = Math.max(0, phase - T0);
layout = buildLayout(cw, ch);
ctx.clearRect(0, 0, cw, ch);
var fadeAlpha = 1;
if (phase > 1650) fadeAlpha = 1 - clamp((phase - 1650) / (1900 - 1650), 0, 1);
for (var k = 0; k < layout.ribbons.length; k += 1) {
var r = layout.ribbons[k];
var p = easeOutCubic(clamp((t - r.delay) / SANKEY_DRAW_DURATION_MS, 0, 1));
if (p <= 0) continue;
drawRibbon(r, p, fadeAlpha);
}
drawNodes();
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) { elapsed += dt; draw(elapsed); }
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedSankeyFlowRibbon.module.scss";
type Ribbon = { j: number; leftTop: number; leftBot: number; rightTop: number; rightBot: number; delay: number };
type Layout = {
nodeW: number; leftX: number; rightX: number;
leftY: number[]; rightY: number[]; leftH: number[]; rightH: number[];
ribbons: Ribbon[];
};
export default function ChartAdvancedSankeyFlowRibbon() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(425011);
const LEFT = ["A", "B", "C"];
const RIGHT = ["P", "Q"];
const FLOW = [
[18 + rand() * 40, 0],
[10 + rand() * 30, 14 + rand() * 36],
[0, 22 + rand() * 44]
];
// 리본이 왼쪽에서 오른쪽으로 그어지는 데 걸리는 시간(ms). 변형은 900 → 1800 으로 바뀐다.
const SANKEY_DRAW_DURATION_MS = 900;
const RIBBON_STAGGER_MS = 90;
function clamp(v: number, a: number, b: number) { return Math.max(a, Math.min(b, v)); }
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
function buildLayout(cwv: number, chv: number): Layout {
const nodeW = Math.max(8, cwv * 0.045);
const marginX = cwv * 0.12;
const marginY = chv * 0.14;
const nodeGap = chv * 0.05;
const leftX = marginX;
const rightX = cwv - marginX - nodeW;
const availH = chv - marginY * 2;
const leftTotal = [0, 0, 0];
const rightTotal = [0, 0];
let i, j;
for (i = 0; i < LEFT.length; i += 1) {
for (j = 0; j < RIGHT.length; j += 1) {
leftTotal[i] += FLOW[i][j];
rightTotal[j] += FLOW[i][j];
}
}
const grandTotal = leftTotal[0] + leftTotal[1] + leftTotal[2];
const leftGaps = nodeGap * (LEFT.length - 1);
const rightGaps = nodeGap * (RIGHT.length - 1);
const usableH = availH - Math.max(leftGaps, rightGaps);
const SCALE = usableH / grandTotal;
const leftH = [leftTotal[0] * SCALE, leftTotal[1] * SCALE, leftTotal[2] * SCALE];
const rightH = [rightTotal[0] * SCALE, rightTotal[1] * SCALE];
const totalLeftH = leftH[0] + leftH[1] + leftH[2] + leftGaps;
const totalRightH = rightH[0] + rightH[1] + rightGaps;
const leftStartY = marginY + (availH - totalLeftH) / 2;
const rightStartY = marginY + (availH - totalRightH) / 2;
const leftY: number[] = [];
let cursorL = leftStartY;
for (i = 0; i < LEFT.length; i += 1) { leftY.push(cursorL); cursorL += leftH[i] + nodeGap; }
const rightY: number[] = [];
let cursorR = rightStartY;
for (j = 0; j < RIGHT.length; j += 1) { rightY.push(cursorR); cursorR += rightH[j] + nodeGap; }
const leftCursor = leftY.slice();
const rightCursor = rightY.slice();
const ribbons: Ribbon[] = [];
let idx = 0;
for (i = 0; i < LEFT.length; i += 1) {
for (j = 0; j < RIGHT.length; j += 1) {
const v = FLOW[i][j];
if (v <= 0) continue;
const thick = v * SCALE;
const leftTop = leftCursor[i];
const leftBot = leftTop + thick;
leftCursor[i] = leftBot;
const rightTop = rightCursor[j];
const rightBot = rightTop + thick;
rightCursor[j] = rightBot;
ribbons.push({ j, leftTop, leftBot, rightTop, rightBot, delay: idx * RIBBON_STAGGER_MS });
idx += 1;
}
}
return { nodeW, leftX, rightX, leftY, rightY, leftH, rightH, ribbons };
}
const COLOR_P = "47,109,246";
const COLOR_Q = "194,54,26";
let cw = 480, ch = 300;
let layout = buildLayout(cw, ch);
function drawRibbon(r: Ribbon, progress: number, fadeAlpha: number) {
const startX = layout.leftX + layout.nodeW;
const endX = layout.rightX;
const midX = (startX + endX) / 2;
const clipRight = lerp(startX, endX, progress);
ctx!.save();
ctx!.beginPath();
ctx!.rect(0, 0, clipRight, ch);
ctx!.clip();
ctx!.beginPath();
ctx!.moveTo(startX, r.leftTop);
ctx!.bezierCurveTo(midX, r.leftTop, midX, r.rightTop, endX, r.rightTop);
ctx!.lineTo(endX, r.rightBot);
ctx!.bezierCurveTo(midX, r.rightBot, midX, r.leftBot, startX, r.leftBot);
ctx!.closePath();
const rgb = r.j === 0 ? COLOR_P : COLOR_Q;
ctx!.fillStyle = "rgba(" + rgb + "," + (0.62 * fadeAlpha) + ")";
ctx!.fill();
ctx!.restore();
}
function drawNodes() {
ctx!.font = Math.max(9, cw * 0.024) + "px sans-serif";
ctx!.textBaseline = "middle";
let i, j;
for (i = 0; i < LEFT.length; i += 1) {
ctx!.fillStyle = "#fff7e6";
ctx!.fillRect(layout.leftX, layout.leftY[i], layout.nodeW, layout.leftH[i]);
ctx!.fillStyle = "rgba(255,247,230,.92)";
ctx!.textAlign = "right";
ctx!.fillText(LEFT[i], layout.leftX - 6, layout.leftY[i] + layout.leftH[i] / 2);
}
for (j = 0; j < RIGHT.length; j += 1) {
ctx!.fillStyle = "#fff7e6";
ctx!.fillRect(layout.rightX, layout.rightY[j], layout.nodeW, layout.rightH[j]);
ctx!.fillStyle = "rgba(255,247,230,.92)";
ctx!.textAlign = "left";
ctx!.fillText(RIGHT[j], layout.rightX + layout.nodeW + 6, layout.rightY[j] + layout.rightH[j] / 2);
}
}
const LOOP_MS = 2000;
const T0 = 200;
function draw(nowMs: number) {
const phase = reduce ? 1500 : (nowMs % LOOP_MS);
const t = Math.max(0, phase - T0);
layout = buildLayout(cw, ch);
ctx!.clearRect(0, 0, cw, ch);
let fadeAlpha = 1;
if (phase > 1650) fadeAlpha = 1 - clamp((phase - 1650) / (1900 - 1650), 0, 1);
for (let k = 0; k < layout.ribbons.length; k += 1) {
const r = layout.ribbons[k];
const p = easeOutCubic(clamp((t - r.delay) / SANKEY_DRAW_DURATION_MS, 0, 1));
if (p <= 0) continue;
drawRibbon(r, p, fadeAlpha);
}
drawNodes();
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) { elapsed += dt; draw(elapsed); }
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.skr} is-demo`} ref={rootRef}>
<canvas className={styles.skr__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.skr__caption}>리본 하나의 굵기를 그 구간이 나르는 값의 크기에 맞춰 계산해, 굵은 리본일수록 더 많은 양이 흘러가는 것을 한눈에 보여준다</p>
</div>
</main>
);
}01 太さが流量を表すサンキー・リボン図 — 計測 累積面積 31.643% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · グラフ 種類 9選 — 計算で描くデータ可視化チャート
このデモが属する記事
グラフ 種類 9選 — 計算で描くデータ可視化チャートグラフ 種類を実際の計算式だけで描き分けたセットです。九種類のうち三つはリボンのように流れ、三つは棒で途切れ、二つは円にまとまり、一つは漏斗状に狭まる。 この記事のデモをすべて見る


