グラフ 種類 9選 — 計算で描くデータ可視化チャート
グラフ 種類を実際の計算式だけで描き分けたセットです。九種類のうち三つはリボンのように流れ、三つは棒で途切れ、二つは円にまとまり、一つは漏斗状に狭まる。
01 chart-advanced-sankey-flow-ribbon
02 chart-advanced-waterfall-bridge-connector
03 chart-advanced-funnel-stage-taper
04 chart-advanced-bubble-pack-collision
05 chart-advanced-boxplot-quartile-whisker
06 chart-advanced-parallel-coordinates-brush
07 chart-advanced-coxcomb-radial-petal
08 chart-advanced-bump-rank-crossing
09 chart-advanced-candlestick-ohlc-wick
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 太さが流量を表すサンキー・リボン図
- 02 増減をつなぐウォーターフォール・ブリッジチャート
- 03 段階ごとに幅が狭まるファネルチャート
- 04 値の大きさぶん円が押し合うバブルパックチャート
- 05 四分位数を箱とひげで描くボックスプロット
- 06 複数の軸を横切る線で値をつなぐ平行座標チャート
- 07 値ごとに半径が異なる花びらで広がるコクスコムチャート
- 08 順位が入れ替わると線が交差するバンプチャート
- 09 実体とひげで四本値を描くローソク足チャート
グラフ 種類 9つ — 値ひとつが描画をどう変えるか
グラフ 種類 名前を人気順に並べる記事は多いですが、この記事ではひとつの値が描画のどこをどれだけ変えるかという基準で並べています。01番は流量ひとつをリボンの太さに変え、02〜04番は累計値・段階の割合・カテゴリの大きさをそれぞれ棒の高さ・台形の幅・円の半径に変え、05番はソートした配列を箱ひげ図の形に変えます。続く06〜08番は複数の値を軸・ブラシ・順位で同時に比較し、09番は始値・高値・安値・終値という4つの数値を1本のローソク足にまとめて締めくくります。この順番で読むと、同じ考え方がJavaScriptの数値ひとつから図形ひとつへとスケールしていく様子がわかります。
チャート種類別コード
このセットの裏にはindex.html/style.scssの組が9つあり、チャート種類ごとにキャンバスが1枚ずつ対応していて、以下のコードはデモが実際に実行しているファイルからそのまま抜き出しています。サンキーダイアグラムとは何かをスクリーンショットではなくリボンの計算式で知りたいなら、まず01番から読むのがおすすめです。
01太さが流量を表すサンキー・リボン図
リボン一本の太さをその区間が運ぶ値の大きさに合わせて計算し、太いリボンほど多くの量が流れていることを一目で示します。予算配分・流入経路レポートのように、数字を読まなくても流量の大小を比較したい場面に向いています。

<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 に同梱 · パスワードを見る
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) {
01番は19/23 フレームで塗り直しが起き、累積面積31.6%・平均強度88.9を記録しました。
02増減をつなぐウォーターフォール・ブリッジチャート
ある棒が終わる高さから次の棒が始まるよう累積値を計算し、増減の過程が橋のようにつながって見えます。月次売上増減のブリーフィング資料のように、どの変化が合計を一番動かしたかを見せたい場面に向いています。

<main class="stage">
<div class="wfb is-demo" id="wfbRoot">
<canvas class="wfb__canvas" id="wfbCanvas" width="480" height="300"></canvas>
<p class="wfb__caption">ある棒が終わる高さから次の棒が始まるように累積値を計算し、値が上下する過程が橋のようにつながって見える。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.wfb {
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-paper;
}
.wfb__canvas {
display: block;
width: 100%;
height: 100%;
}
.wfb__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(23, 20, 26, .07);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wfb__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.wfb { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wfb {
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: #ffffff;
}
.wfb__canvas {
display: block;
width: 100%;
height: 100%;
}
.wfb__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wfb__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.wfb {
cursor: default;
}
}(function () {
// 기법: 시작·종료 총합과 4개 증감값에서 누적값 배열을 계산해 막대 y 위치를 실제 값에 그대로
// 매핑하고(하드코딩 좌표 없음), 막대마다 순서대로 지연을 두어 이전 누적값에서 다음 누적값
// 쪽으로 높이가 자라도록 그린다. 다 자란 막대 사이는 점선으로 이어 다리처럼 보이게 한다.
var root = document.getElementById('wfbRoot');
var canvas = document.getElementById('wfbCanvas');
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(425021);
var deltas = [18 + rand() * 22, -(10 + rand() * 20), 14 + rand() * 26, -(8 + rand() * 18)];
var startVal = 40 + rand() * 10;
var running = [startVal];
for (var di = 0; di < deltas.length; di += 1) running.push(running[running.length - 1] + deltas[di]);
var endVal = running[running.length - 1];
var BAR_GROW_MS = 260;
var BAR_STAGGER_MS = 210;
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); }
// bars[0]·bars[5] 는 총합, bars[1..4] 는 증감. base/top 은 애니메이션이 자라나는 기준점(anchor)이다.
var bars = [
{ kind: 'total', value: startVal, anchor: 0, target: startVal },
{ kind: 'delta', value: deltas[0], anchor: running[0], target: running[1] },
{ kind: 'delta', value: deltas[1], anchor: running[1], target: running[2] },
{ kind: 'delta', value: deltas[2], anchor: running[2], target: running[3] },
{ kind: 'delta', value: deltas[3], anchor: running[3], target: running[4] },
{ kind: 'total', value: endVal, anchor: 0, target: endVal }
];
var COLOR_TOTAL = '#0a4fe8';
var COLOR_UP = '#2b9d74';
var COLOR_DOWN = '#c2361a';
var LOOP_MS = 2000;
var T0 = 200;
var cw = 480, ch = 300;
function draw(nowMs) {
var phase = reduce ? 1500 : (nowMs % LOOP_MS);
var t = Math.max(0, phase - T0);
ctx.clearRect(0, 0, cw, ch);
var fadeAlpha = 1;
if (phase > 1650) fadeAlpha = 1 - clamp((phase - 1650) / (1950 - 1650), 0, 1);
var maxVal = startVal;
var i;
for (i = 0; i < running.length; i += 1) maxVal = Math.max(maxVal, running[i]);
maxVal *= 1.12;
var marginX = cw * 0.06;
var chartTop = ch * 0.12;
var chartBottom = ch * 0.8;
var chartH = chartBottom - chartTop;
var scaleY = chartH / maxVal;
function yFor(v) { return chartBottom - v * scaleY; }
var n = bars.length;
var slotW = (cw - marginX * 2) / n;
var barW = slotW * 0.58;
ctx.globalAlpha = fadeAlpha;
// 다 자란 막대 사이 점선 연결선
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(23,20,26,.55)';
ctx.lineWidth = 1;
for (i = 0; i < n - 1; i += 1) {
var p1 = easeOutCubic(clamp((t - bars[i].delay) / BAR_GROW_MS, 0, 1));
if (p1 < 1) continue;
var x1 = marginX + slotW * i + (slotW - barW) / 2 + barW;
var x2 = marginX + slotW * (i + 1) + (slotW - barW) / 2;
var boundaryVal = i === 0 ? startVal : running[i];
var y = yFor(boundaryVal);
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.stroke();
}
ctx.setLineDash([]);
for (i = 0; i < n; i += 1) {
var b = bars[i];
var delay = i * BAR_STAGGER_MS;
var progress = easeOutCubic(clamp((t - delay) / BAR_GROW_MS, 0, 1));
var currentVal = lerp(b.anchor, b.target, progress);
var topVal = Math.max(b.anchor, currentVal);
var botVal = Math.min(b.anchor, currentVal);
var x = marginX + slotW * i + (slotW - barW) / 2;
var y1 = yFor(topVal);
var y2 = yFor(botVal);
var color = b.kind === 'total' ? COLOR_TOTAL : (b.value >= 0 ? COLOR_UP : COLOR_DOWN);
ctx.fillStyle = color;
ctx.fillRect(x, y1, barW, Math.max(1, y2 - y1));
}
ctx.globalAlpha = 1;
}
// 막대별 지연을 미리 붙여 둔다(연결선 계산에서도 재사용)
for (var bi = 0; bi < bars.length; bi += 1) bars[bi].delay = bi * BAR_STAGGER_MS;
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 "./ChartAdvancedWaterfallBridgeConnector.module.scss";
type Bar = { kind: "total" | "delta"; value: number; anchor: number; target: number; delay: number };
export default function ChartAdvancedWaterfallBridgeConnector() {
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(425021);
const deltas = [18 + rand() * 22, -(10 + rand() * 20), 14 + rand() * 26, -(8 + rand() * 18)];
const startVal = 40 + rand() * 10;
const running = [startVal];
for (let di = 0; di < deltas.length; di += 1) running.push(running[running.length - 1] + deltas[di]);
const endVal = running[running.length - 1];
const BAR_GROW_MS = 260;
const BAR_STAGGER_MS = 210;
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); }
// bars[0]·bars[5] 는 총합, bars[1..4] 는 증감. anchor/target 은 애니메이션이 자라나는 기준점이다.
const bars: Bar[] = [
{ kind: "total", value: startVal, anchor: 0, target: startVal, delay: 0 },
{ kind: "delta", value: deltas[0], anchor: running[0], target: running[1], delay: BAR_STAGGER_MS },
{ kind: "delta", value: deltas[1], anchor: running[1], target: running[2], delay: BAR_STAGGER_MS * 2 },
{ kind: "delta", value: deltas[2], anchor: running[2], target: running[3], delay: BAR_STAGGER_MS * 3 },
{ kind: "delta", value: deltas[3], anchor: running[3], target: running[4], delay: BAR_STAGGER_MS * 4 },
{ kind: "total", value: endVal, anchor: 0, target: endVal, delay: BAR_STAGGER_MS * 5 }
];
const COLOR_TOTAL = "#0a4fe8";
const COLOR_UP = "#2b9d74";
const COLOR_DOWN = "#c2361a";
const LOOP_MS = 2000;
const T0 = 200;
let cw = 480, ch = 300;
function draw(nowMs: number) {
const phase = reduce ? 1500 : (nowMs % LOOP_MS);
const t = Math.max(0, phase - T0);
ctx!.clearRect(0, 0, cw, ch);
let fadeAlpha = 1;
if (phase > 1650) fadeAlpha = 1 - clamp((phase - 1650) / (1950 - 1650), 0, 1);
let maxVal = startVal;
let i;
for (i = 0; i < running.length; i += 1) maxVal = Math.max(maxVal, running[i]);
maxVal *= 1.12;
const marginX = cw * 0.06;
const chartTop = ch * 0.12;
const chartBottom = ch * 0.8;
const chartH = chartBottom - chartTop;
const scaleY = chartH / maxVal;
function yFor(v: number) { return chartBottom - v * scaleY; }
const n = bars.length;
const slotW = (cw - marginX * 2) / n;
const barW = slotW * 0.58;
ctx!.globalAlpha = fadeAlpha;
ctx!.setLineDash([4, 4]);
ctx!.strokeStyle = "rgba(23,20,26,.55)";
ctx!.lineWidth = 1;
for (i = 0; i < n - 1; i += 1) {
const p1 = easeOutCubic(clamp((t - bars[i].delay) / BAR_GROW_MS, 0, 1));
if (p1 < 1) continue;
const x1 = marginX + slotW * i + (slotW - barW) / 2 + barW;
const x2 = marginX + slotW * (i + 1) + (slotW - barW) / 2;
const boundaryVal = i === 0 ? startVal : running[i];
const y = yFor(boundaryVal);
ctx!.beginPath();
ctx!.moveTo(x1, y);
ctx!.lineTo(x2, y);
ctx!.stroke();
}
ctx!.setLineDash([]);
for (i = 0; i < n; i += 1) {
const b = bars[i];
const progress = easeOutCubic(clamp((t - b.delay) / BAR_GROW_MS, 0, 1));
const currentVal = lerp(b.anchor, b.target, progress);
const topVal = Math.max(b.anchor, currentVal);
const botVal = Math.min(b.anchor, currentVal);
const x = marginX + slotW * i + (slotW - barW) / 2;
const y1 = yFor(topVal);
const y2 = yFor(botVal);
const color = b.kind === "total" ? COLOR_TOTAL : (b.value >= 0 ? COLOR_UP : COLOR_DOWN);
ctx!.fillStyle = color;
ctx!.fillRect(x, y1, barW, Math.max(1, y2 - y1));
}
ctx!.globalAlpha = 1;
}
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.wfb} is-demo`} ref={rootRef}>
<canvas className={styles.wfb__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.wfb__caption}>막대 하나가 끝나는 높이에서 다음 막대가 시작하도록 누적값을 계산해, 값이 오르내리는 과정이 다리처럼 이어져 보인다</p>
</div>
</main>
);
}02 · 増減をつなぐウォーターフォール・ブリッジチャート — 計測 累積面積 10.019% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var BAR_GROW_MS = 260;
var BAR_STAGGER_MS = 210;
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); }
// bars[0]とbars[5]は合計、bars[1..4]は増減。anchorはバーの伸びるアニメーションの起点。
var bars = [
{ kind: 'total', value: startVal, anchor: 0, target: startVal },
{ kind: 'delta', value: deltas[0], anchor: running[0], target: running[1] },
{ kind: 'delta', value: deltas[1], anchor: running[1], target: running[2] },
{ kind: 'delta', value: deltas[2], anchor: running[2], target: running[3] },
{ kind: 'delta', value: deltas[3], anchor: running[3], target: running[4] },
{ kind: 'total', value: endVal, anchor: 0, target: endVal }
];
var COLOR_TOTAL = '#0a4fe8';
var COLOR_UP = '#2b9d74';
var COLOR_DOWN = '#c2361a';
02番は累積面積10.0%・平均強度171.7と、9つの中でピクセル単位の変化が最も鋭く、22/23 フレームで動きました。
03段階ごとに幅が狭まるファネルチャート
各段階の値の割合ぶんだけ台形の幅を計算して狭め、前段階からの離脱が幅の差として現れます。登録から決済までの転換ファネルレポート向けで、最も離脱が大きい段階が見た目で分かります。

<main class="stage">
<div class="fst is-demo" id="fstRoot">
<canvas class="fst__canvas" id="fstCanvas" width="480" height="300"></canvas>
<p class="fst__caption">各段階の値の比率に合わせて台形の幅を計算して狭め、前の段階からどれだけ離脱したかが幅の差で表れる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.fst {
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-paper;
}
.fst__canvas {
display: block;
width: 100%;
height: 100%;
}
.fst__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(23, 20, 26, .07);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.fst__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.fst { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fst {
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: #ffffff;
}
.fst__canvas {
display: block;
width: 100%;
height: 100%;
}
.fst__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.fst__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fst {
cursor: default;
}
}(function () {
// 기법: 시드로 두 데이터셋(A/B)을 만들고, 삼각파 blend 값으로 두 데이터셋을 단계별 값
// 단위에서 lerp 해 매 프레임 사다리꼴 폭을 다시 계산한다. 정적인 두 장을 겹쳐 페이드하는
// 것이 아니라 값 자체가 매 프레임 보간되므로 폭이 실제로 넓어지고 좁아진다.
var root = document.getElementById('fstRoot');
var canvas = document.getElementById('fstCanvas');
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(425031);
function makeFunnel() {
var v = 100;
var out = [v];
for (var i = 0; i < 4; i += 1) {
v = v * (0.55 + rand() * 0.25);
out.push(v);
}
return out;
}
var DATASET_A = makeFunnel();
var DATASET_B = makeFunnel();
var STAGE_COLORS = ['#2f6df6', '#c2361a', '#2b9d74', '#8a5cd6', '#0a4fe8'];
var STAGE_COLORS_B = ['#8ec5ff', '#ffb199', '#8be0c2', '#d7c2fa', '#bcd4ff'];
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function hexToRgb(hex) {
var n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lerpColor(hexA, hexB, t) {
var a = hexToRgb(hexA), b = hexToRgb(hexB);
return 'rgb(' + Math.round(lerp(a[0], b[0], t)) + ',' + Math.round(lerp(a[1], b[1], t)) + ',' + Math.round(lerp(a[2], b[2], t)) + ')';
}
var LOOP_MS = 800;
var T0 = 200;
var cw = 480, ch = 300;
function draw(nowMs) {
var phase = reduce ? 1200 : (nowMs % LOOP_MS);
var t = Math.max(0, phase - T0);
ctx.clearRect(0, 0, cw, ch);
var half = (LOOP_MS - T0) / 2;
var blend = t < half ? t / half : 2 - t / half;
blend = clamp(blend, 0, 1);
var values = [];
var i;
for (i = 0; i < DATASET_A.length; i += 1) values.push(lerp(DATASET_A[i], DATASET_B[i], blend));
var marginY = ch * 0.05;
var availH = ch - marginY * 2;
var bandH = availH / values.length;
var maxWidth = cw * 0.92;
var cx = cw / 2;
var baseFont = Math.max(10, cw * 0.026);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (i = 0; i < values.length; i += 1) {
var topVal = values[i];
var botVal = i < values.length - 1 ? values[i + 1] : values[i] * 0.86;
var topW = (topVal / 100) * maxWidth;
var botW = (botVal / 100) * maxWidth;
var y0 = marginY + bandH * i;
var y1 = y0 + bandH;
ctx.beginPath();
ctx.moveTo(cx - topW / 2, y0);
ctx.lineTo(cx + topW / 2, y0);
ctx.lineTo(cx + botW / 2, y1);
ctx.lineTo(cx - botW / 2, y1);
ctx.closePath();
ctx.fillStyle = lerpColor(STAGE_COLORS[i], STAGE_COLORS_B[i], blend);
ctx.fill();
var dropRate = i > 0 ? Math.max(0, (values[i - 1] - topVal) / values[i - 1] * 100) : 0;
ctx.font = baseFont + 'px sans-serif';
ctx.fillStyle = 'rgba(255,247,230,.95)';
ctx.fillText(String(Math.round(topVal)), cx, y0 + bandH / 2 - baseFont * 0.5);
if (i > 0) {
ctx.font = (baseFont * 0.78) + 'px sans-serif';
ctx.fillStyle = 'rgba(255,247,230,.78)';
ctx.fillText('-' + dropRate.toFixed(0) + '%', cx, y0 + bandH / 2 + baseFont * 0.62);
}
}
}
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 "./ChartAdvancedFunnelStageTaper.module.scss";
export default function ChartAdvancedFunnelStageTaper() {
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(425031);
function makeFunnel() {
let v = 100;
const out = [v];
for (let i = 0; i < 4; i += 1) {
v = v * (0.55 + rand() * 0.25);
out.push(v);
}
return out;
}
const DATASET_A = makeFunnel();
const DATASET_B = makeFunnel();
const STAGE_COLORS = ["#2f6df6", "#c2361a", "#2b9d74", "#8a5cd6", "#0a4fe8"];
const STAGE_COLORS_B = ["#8ec5ff", "#ffb199", "#8be0c2", "#d7c2fa", "#bcd4ff"];
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 hexToRgb(hex: string) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lerpColor(hexA: string, hexB: string, t: number) {
const a = hexToRgb(hexA), b = hexToRgb(hexB);
return "rgb(" + Math.round(lerp(a[0], b[0], t)) + "," + Math.round(lerp(a[1], b[1], t)) + "," + Math.round(lerp(a[2], b[2], t)) + ")";
}
const LOOP_MS = 800;
const T0 = 200;
let cw = 480, ch = 300;
function draw(nowMs: number) {
const phase = reduce ? 1200 : (nowMs % LOOP_MS);
const t = Math.max(0, phase - T0);
ctx!.clearRect(0, 0, cw, ch);
const half = (LOOP_MS - T0) / 2;
let blend = t < half ? t / half : 2 - t / half;
blend = clamp(blend, 0, 1);
const values: number[] = [];
let i;
for (i = 0; i < DATASET_A.length; i += 1) values.push(lerp(DATASET_A[i], DATASET_B[i], blend));
const marginY = ch * 0.05;
const availH = ch - marginY * 2;
const bandH = availH / values.length;
const maxWidth = cw * 0.92;
const cx = cw / 2;
const baseFont = Math.max(10, cw * 0.026);
ctx!.textAlign = "center";
ctx!.textBaseline = "middle";
for (i = 0; i < values.length; i += 1) {
const topVal = values[i];
const botVal = i < values.length - 1 ? values[i + 1] : values[i] * 0.86;
const topW = (topVal / 100) * maxWidth;
const botW = (botVal / 100) * maxWidth;
const y0 = marginY + bandH * i;
const y1 = y0 + bandH;
ctx!.beginPath();
ctx!.moveTo(cx - topW / 2, y0);
ctx!.lineTo(cx + topW / 2, y0);
ctx!.lineTo(cx + botW / 2, y1);
ctx!.lineTo(cx - botW / 2, y1);
ctx!.closePath();
ctx!.fillStyle = lerpColor(STAGE_COLORS[i], STAGE_COLORS_B[i], blend);
ctx!.fill();
const dropRate = i > 0 ? Math.max(0, (values[i - 1] - topVal) / values[i - 1] * 100) : 0;
ctx!.font = baseFont + "px sans-serif";
ctx!.fillStyle = "rgba(255,247,230,.95)";
ctx!.fillText(String(Math.round(topVal)), cx, y0 + bandH / 2 - baseFont * 0.5);
if (i > 0) {
ctx!.font = (baseFont * 0.78) + "px sans-serif";
ctx!.fillStyle = "rgba(255,247,230,.78)";
ctx!.fillText("-" + dropRate.toFixed(0) + "%", cx, y0 + bandH / 2 + baseFont * 0.62);
}
}
}
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.fst} is-demo`} ref={rootRef}>
<canvas className={styles.fst__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.fst__caption}>각 단계의 값 비율만큼 사다리꼴 폭을 계산해 좁혀, 이전 단계에서 얼마나 이탈했는지가 폭 차이로 드러난다</p>
</div>
</main>
);
}03 · 段階ごとに幅が狭まるファネルチャート — 計測 累積面積 29.776% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var rand = seedRand(425031);
function makeFunnel() {
var v = 100;
var out = [v];
for (var i = 0; i < 4; i += 1) {
v = v * (0.55 + rand() * 0.25);
out.push(v);
}
return out;
}
var DATASET_A = makeFunnel();
var DATASET_B = makeFunnel();
var STAGE_COLORS = ['#2f6df6', '#c2361a', '#2b9d74', '#8a5cd6', '#0a4fe8'];
03番は累積面積29.8%・平均強度40.7、21/23 フレームで動きました。
04値の大きさぶん円が押し合うバブルパックチャート
値が大きい項目ほど半径を大きく取り、円同士が重ならなくなるまで位置を繰り返し計算し、大きさだけで値を比較できます。カテゴリ別売上比率の比較カードのように、軸を置く余白がない小さなダッシュボードのタイルに向いています。

<main class="stage">
<div class="bpk is-demo" id="bpkRoot">
<canvas class="bpk__canvas" id="bpkCanvas" width="480" height="300"></canvas>
<p class="bpk__caption">値が大きいほど半径を大きく取り、円同士が重ならなくなるまで位置を繰り返し計算するので、大きさだけで値を比較できる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.bpk {
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-paper;
}
.bpk__canvas {
display: block;
width: 100%;
height: 100%;
}
.bpk__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(23, 20, 26, .82);
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bpk__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bpk { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bpk {
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: #ffffff;
}
.bpk__canvas {
display: block;
width: 100%;
height: 100%;
}
.bpk__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bpk__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bpk {
cursor: default;
}
}(function () {
// 기법: 값마다 반지름을 정한 뒤 겹치는 원 쌍을 찾아 겹친 만큼 절반씩 밀어내는 완화
// (relaxation) 을 정해진 횟수만큼 반복한다. 매 반복 뒤의 좌표를 스냅샷으로 저장해두고,
// 재생 시점에는 그 스냅샷 사이를 시간에 따라 보간해서 그린다 — 물리 엔진 없이 배열
// 하나로 원들이 서서히 자리를 잡아 벌어지는 과정을 재현한다.
var root = document.getElementById('bpkRoot');
var canvas = document.getElementById('bpkCanvas');
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 CALC_W = 480, CALC_H = 300;
var LOOP_MS = 2000;
var T0 = 200;
var COUNT = 7;
var K = 3.1;
var BUBBLE_PACK_COLLISION_ITERATIONS = 30;
function buildPack(seed) {
var pr = seedRand(seed);
var values = [];
for (var vi = 0; vi < COUNT; vi += 1) values.push(30 + pr() * 220);
var radii = values.map(function (v) { return Math.sqrt(v) * K; });
var packCenterX = CALC_W * 0.5;
var packCenterY = CALC_H * 0.5;
var initialPositions = [];
for (var pi = 0; pi < COUNT; pi += 1) {
initialPositions.push({
x: packCenterX + (pr() - 0.5) * 46,
y: packCenterY + (pr() - 0.5) * 46
});
}
var snapshots = [initialPositions.map(function (p) { return { x: p.x, y: p.y }; })];
var relaxed = initialPositions.map(function (p) { return { x: p.x, y: p.y }; });
for (var iter = 0; iter < BUBBLE_PACK_COLLISION_ITERATIONS; iter += 1) {
for (var a = 0; a < COUNT; a += 1) {
for (var b = a + 1; b < COUNT; b += 1) {
var pa = relaxed[a], pb = relaxed[b];
var dx = pb.x - pa.x, dy = pb.y - pa.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var minDist = radii[a] + radii[b];
if (dist < minDist) {
if (dist < 0.001) {
var ang = ((a * 37 + b * 53 + seed) % 360) * Math.PI / 180;
dx = Math.cos(ang); dy = Math.sin(ang); dist = 0.001;
}
var overlap = minDist - dist;
var nx = dx / dist, ny = dy / dist;
var push = overlap * 0.5;
pa.x -= nx * push; pa.y -= ny * push;
pb.x += nx * push; pb.y += ny * push;
}
}
}
for (var c = 0; c < COUNT; c += 1) {
var pc = relaxed[c], rc = radii[c];
pc.x = Math.max(rc, Math.min(CALC_W - rc, pc.x));
pc.y = Math.max(rc, Math.min(CALC_H - rc, pc.y));
}
snapshots.push(relaxed.map(function (p) { return { x: p.x, y: p.y }; }));
}
return { radii: radii, snapshots: snapshots };
}
// 값 리셔플 2회: 세 팩을 시연 구간에 나눠 배치해, 완화가 한 번에 끝나지 않고
// 시연 내내 진행되다가 경계마다 다시 밀어내는 과정을 두 번 더 보여준다.
var PACKS = [buildPack(425041), buildPack(425042), buildPack(425043)];
var FILL_COLORS = ['47,109,246', '194,54,26', '43,157,116', '138,92,214'];
var INK_STROKE = '23,20,26';
function lerp(x0, x1, f) { return x0 + (x1 - x0) * f; }
var cw = 480, ch = 300;
function draw(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
var et = Math.max(0, t - T0);
var seg = (LOOP_MS - T0) / PACKS.length;
var segIdx = Math.min(PACKS.length - 1, Math.floor(et / seg));
var localT = et - segIdx * seg;
var unit = Math.max(0, Math.min(1, localT / seg));
var tri = unit < 0.5 ? unit * 2 : 2 - unit * 2;
// 완화 초반(적은 반복)이 위치 변화가 크고 후반은 변화가 작아, 시간을 선형으로 매핑하면
// 뒤쪽 프레임이 정지해 보인다 — frac 을 볼록(위로 가속)하게 눌러 반복 인덱스가
// 초반엔 느리게(그래도 위치는 크게 바뀜), 후반엔 빠르게 지나가 프레임마다 변화가 이어진다.
var frac = Math.pow(tri, 3.6);
var iterFrac = frac * BUBBLE_PACK_COLLISION_ITERATIONS;
var lo = Math.floor(iterFrac);
var hi = Math.min(lo + 1, BUBBLE_PACK_COLLISION_ITERATIONS);
var f2 = iterFrac - lo;
var pack = PACKS[segIdx];
var snapLo = pack.snapshots[lo], snapHi = pack.snapshots[hi];
var radii = pack.radii;
for (var i = 0; i < COUNT; i += 1) {
var px = lerp(snapLo[i].x, snapHi[i].x, f2);
var py = lerp(snapLo[i].y, snapHi[i].y, f2);
var dx = px * sx, dy = py * sy, dr = radii[i] * ((sx + sy) / 2);
ctx.beginPath();
ctx.fillStyle = 'rgba(' + FILL_COLORS[i % 4] + ',0.8)';
ctx.strokeStyle = 'rgba(' + INK_STROKE + ',1)';
ctx.lineWidth = 1;
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function phaseOf(ms) { return reduce ? LOOP_MS * 0.72 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(phaseOf(elapsed));
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedBubblePackCollision.module.scss";
type Pos = { x: number; y: number };
export default function ChartAdvancedBubblePackCollision() {
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 CALC_W = 480, CALC_H = 300;
const LOOP_MS = 2000;
const T0 = 200;
const COUNT = 7;
const K = 3.1;
const BUBBLE_PACK_COLLISION_ITERATIONS = 30;
function buildPack(seed: number) {
const pr = seedRand(seed);
const values: number[] = [];
for (let vi = 0; vi < COUNT; vi += 1) values.push(30 + pr() * 220);
const radii = values.map(function (v) { return Math.sqrt(v) * K; });
const packCenterX = CALC_W * 0.5;
const packCenterY = CALC_H * 0.5;
const initialPositions: Pos[] = [];
for (let pi = 0; pi < COUNT; pi += 1) {
initialPositions.push({
x: packCenterX + (pr() - 0.5) * 46,
y: packCenterY + (pr() - 0.5) * 46
});
}
const snapshots: Pos[][] = [initialPositions.map(function (p) { return { x: p.x, y: p.y }; })];
const relaxed: Pos[] = initialPositions.map(function (p) { return { x: p.x, y: p.y }; });
for (let iter = 0; iter < BUBBLE_PACK_COLLISION_ITERATIONS; iter += 1) {
for (let a = 0; a < COUNT; a += 1) {
for (let b = a + 1; b < COUNT; b += 1) {
const pa = relaxed[a], pb = relaxed[b];
let dx = pb.x - pa.x, dy = pb.y - pa.y;
let dist = Math.sqrt(dx * dx + dy * dy);
const minDist = radii[a] + radii[b];
if (dist < minDist) {
if (dist < 0.001) {
const ang = ((a * 37 + b * 53 + seed) % 360) * Math.PI / 180;
dx = Math.cos(ang); dy = Math.sin(ang); dist = 0.001;
}
const overlap = minDist - dist;
const nx = dx / dist, ny = dy / dist;
const push = overlap * 0.5;
pa.x -= nx * push; pa.y -= ny * push;
pb.x += nx * push; pb.y += ny * push;
}
}
}
for (let c = 0; c < COUNT; c += 1) {
const pc = relaxed[c], rc = radii[c];
pc.x = Math.max(rc, Math.min(CALC_W - rc, pc.x));
pc.y = Math.max(rc, Math.min(CALC_H - rc, pc.y));
}
snapshots.push(relaxed.map(function (p) { return { x: p.x, y: p.y }; }));
}
return { radii, snapshots };
}
// 값 리셔플 2회: 세 팩을 시연 구간에 나눠 배치해, 완화가 한 번에 끝나지 않고
// 시연 내내 진행되다가 경계마다 다시 밀어내는 과정을 두 번 더 보여준다.
const PACKS = [buildPack(425041), buildPack(425042), buildPack(425043)];
const FILL_COLORS = ["47,109,246", "194,54,26", "43,157,116", "138,92,214"];
const INK_STROKE = "23,20,26";
function lerp(x0: number, x1: number, f: number) { return x0 + (x1 - x0) * f; }
let cw = 480, ch = 300;
function draw(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
const et = Math.max(0, t - T0);
const seg = (LOOP_MS - T0) / PACKS.length;
const segIdx = Math.min(PACKS.length - 1, Math.floor(et / seg));
const localT = et - segIdx * seg;
const unit = Math.max(0, Math.min(1, localT / seg));
const tri = unit < 0.5 ? unit * 2 : 2 - unit * 2;
// 완화 초반(적은 반복)이 위치 변화가 크고 후반은 변화가 작아, 시간을 선형으로 매핑하면
// 뒤쪽 프레임이 정지해 보인다 — frac 을 볼록(위로 가속)하게 눌러 반복 인덱스가
// 초반엔 느리게(그래도 위치는 크게 바뀜), 후반엔 빠르게 지나가 프레임마다 변화가 이어진다.
const frac = Math.pow(tri, 3.6);
const iterFrac = frac * BUBBLE_PACK_COLLISION_ITERATIONS;
const lo = Math.floor(iterFrac);
const hi = Math.min(lo + 1, BUBBLE_PACK_COLLISION_ITERATIONS);
const f2 = iterFrac - lo;
const pack = PACKS[segIdx];
const snapLo = pack.snapshots[lo], snapHi = pack.snapshots[hi];
const radii = pack.radii;
for (let i = 0; i < COUNT; i += 1) {
const px = lerp(snapLo[i].x, snapHi[i].x, f2);
const py = lerp(snapLo[i].y, snapHi[i].y, f2);
const dx = px * sx, dy = py * sy, dr = radii[i] * ((sx + sy) / 2);
ctx!.beginPath();
ctx!.fillStyle = "rgba(" + FILL_COLORS[i % 4] + ",0.8)";
ctx!.strokeStyle = "rgba(" + INK_STROKE + ",1)";
ctx!.lineWidth = 1;
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
ctx!.stroke();
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function phaseOf(ms: number) { return reduce ? LOOP_MS * 0.72 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bpk} is-demo`} ref={rootRef}>
<canvas className={styles.bpk__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bpk__caption}>값이 큰 항목일수록 반지름을 크게 잡고 원끼리 겹치지 않을 때까지 위치를 반복 계산해, 크기만으로 값이 비교된다.</p>
</div>
</main>
);
}04 · 値の大きさぶん円が押し合うバブルパックチャート — 計測 累積面積 25.791% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var COUNT = 7;
var K = 3.1;
var BUBBLE_PACK_COLLISION_ITERATIONS = 30;
function buildPack(seed) {
var pr = seedRand(seed);
var values = [];
for (var vi = 0; vi < COUNT; vi += 1) values.push(30 + pr() * 220);
var radii = values.map(function (v) { return Math.sqrt(v) * K; });
var packCenterX = CALC_W * 0.5;
var packCenterY = CALC_H * 0.5;
var initialPositions = [];
for (var pi = 0; pi < COUNT; pi += 1) {
initialPositions.push({
x: packCenterX + (pr() - 0.5) * 46,
y: packCenterY + (pr() - 0.5) * 46
});
}
var snapshots = [initialPositions.map(function (p) { return { x: p.x, y: p.y }; })];
var relaxed = initialPositions.map(function (p) { return { x: p.x, y: p.y }; });
for (var iter = 0; iter < BUBBLE_PACK_COLLISION_ITERATIONS; iter += 1) {
04番は累積面積25.8%・平均強度117.6、18/23 フレームで動きました。
05四分位数を箱とひげで描くボックスプロット
値を並べ替えて四分位の境界を計算し、箱の高さとひげの長さに変換して描くことで、中央値とばらつきが一つの図に収まります。グループ別レスポンスタイム分布の比較のように、平均だけでなくばらつきも重要な場面に向いています。

<main class="stage">
<div class="bxw is-demo" id="bxwRoot">
<canvas class="bxw__canvas" id="bxwCanvas" width="480" height="300"></canvas>
<p class="bxw__caption">値を並び替えて四分位境界を計算し、箱の高さとひげの長さに置き換えて描くことで、中央値とばらつきの度合いが一つの図形に収まる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bxw {
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;
}
.bxw__canvas {
display: block;
width: 100%;
height: 100%;
}
.bxw__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) {
.bxw__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bxw { 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);
}
.bxw {
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;
}
.bxw__canvas {
display: block;
width: 100%;
height: 100%;
}
.bxw__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) {
.bxw__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bxw {
cursor: default;
}
}(function () {
// 기법: 두 그룹 각각에 표본 집합을 두 벌씩 두고 사분위(최소·1사분위·중앙값·3사분위·최대)를
// 미리 계산해둔다. 매 프레임 삼각파(0→1→0)로 두 표본 집합의 사분위 값을 요소별로 보간해
// 그 프레임의 상자·수염 좌표를 다시 계산해서 그린다 — 정적인 두 장을 겹쳐 페이드하는 것이
// 아니라 통계값 자체가 시간에 따라 움직인다.
var root = document.getElementById('bxwRoot');
var canvas = document.getElementById('bxwCanvas');
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 CALC_W = 480, CALC_H = 300;
var LOOP_MS = 800;
var T0 = 200;
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
var rand = seedRand(425051);
function sample(n) {
var out = [];
for (var i = 0; i < n; i += 1) out.push(20 + rand() * 80);
return out;
}
var GROUP_A_1 = sample(9), GROUP_A_2 = sample(9);
var GROUP_B_1 = sample(9), GROUP_B_2 = sample(9);
function quartiles(arr) {
var s = arr.slice().sort(function (a, b) { return a - b; });
function pct(p) {
var idx = (s.length - 1) * p, lo = Math.floor(idx), hi = Math.ceil(idx);
return s[lo] + (s[hi] - s[lo]) * (idx - lo);
}
return { min: s[0], q1: pct(0.25), med: pct(0.5), q3: pct(0.75), max: s[s.length - 1] };
}
var qA1 = quartiles(GROUP_A_1), qA2 = quartiles(GROUP_A_2);
var qB1 = quartiles(GROUP_B_1), qB2 = quartiles(GROUP_B_2);
function lerp(x0, x1, f) { return x0 + (x1 - x0) * f; }
function lerpStats(s1, s2, f) {
return {
min: lerp(s1.min, s2.min, f),
q1: lerp(s1.q1, s2.q1, f),
med: lerp(s1.med, s2.med, f),
q3: lerp(s1.q3, s2.q3, f),
max: lerp(s1.max, s2.max, f)
};
}
var DOMAIN_MIN = 10, DOMAIN_MAX = 115;
var PLOT_TOP = 22, PLOT_BOTTOM = 292;
function valueToY(v) {
var f = (v - DOMAIN_MIN) / (DOMAIN_MAX - DOMAIN_MIN);
return PLOT_BOTTOM - f * (PLOT_BOTTOM - PLOT_TOP);
}
var BOX_FILL = ['47,109,246', '43,157,116'];
var CREAM_LINE = '255,247,230';
var BOX_WIDTH = 172;
var GROUP_X = [134, 346];
var OUTLIER_MULT = 1.2;
function drawBox(cx, stats, fillRgb, sx, sy, showOutlier) {
var yMin = valueToY(stats.min), yQ1 = valueToY(stats.q1);
var yMed = valueToY(stats.med), yQ3 = valueToY(stats.q3), yMax = valueToY(stats.max);
var xLeft = cx - BOX_WIDTH / 2;
var capHalf = BOX_WIDTH * 0.22;
ctx.strokeStyle = 'rgba(' + CREAM_LINE + ',.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx * sx, yMax * sy); ctx.lineTo(cx * sx, yQ3 * sy); ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx * sx, yQ1 * sy); ctx.lineTo(cx * sx, yMin * sy); ctx.stroke();
ctx.beginPath();
ctx.moveTo((cx - capHalf) * sx, yMax * sy); ctx.lineTo((cx + capHalf) * sx, yMax * sy); ctx.stroke();
ctx.beginPath();
ctx.moveTo((cx - capHalf) * sx, yMin * sy); ctx.lineTo((cx + capHalf) * sx, yMin * sy); ctx.stroke();
ctx.fillStyle = 'rgba(' + fillRgb + ',.7)';
ctx.fillRect(xLeft * sx, yQ3 * sy, BOX_WIDTH * sx, (yQ1 - yQ3) * sy);
ctx.strokeStyle = 'rgba(' + CREAM_LINE + ',.9)';
ctx.strokeRect(xLeft * sx, yQ3 * sy, BOX_WIDTH * sx, (yQ1 - yQ3) * sy);
ctx.beginPath();
ctx.moveTo(xLeft * sx, yMed * sy);
ctx.lineTo((xLeft + BOX_WIDTH) * sx, yMed * sy);
ctx.lineWidth = 2.4;
ctx.stroke();
if (showOutlier) {
var yOut = valueToY(stats.max * OUTLIER_MULT);
ctx.beginPath();
ctx.fillStyle = 'rgba(' + CREAM_LINE + ',.95)';
ctx.arc(cx * sx, yOut * sy, 4.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(' + fillRgb + ',.95)';
ctx.lineWidth = 1.6;
ctx.stroke();
}
}
var cw = 480, ch = 300;
function draw(phase) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
var t = Math.max(0, phase - T0);
var half = (LOOP_MS - T0) / 2;
var blend = t < half ? t / half : 2 - t / half;
blend = clamp(blend, 0, 1);
var statsA = lerpStats(qA1, qA2, blend);
var statsB = lerpStats(qB1, qB2, blend);
drawBox(GROUP_X[0], statsA, BOX_FILL[0], sx, sy, blend > 0.62);
drawBox(GROUP_X[1], statsB, BOX_FILL[1], sx, sy, blend < 0.38);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function phaseOf(ms) { return reduce ? LOOP_MS * 0.5 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(phaseOf(elapsed));
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedBoxplotQuartileWhisker.module.scss";
type Stats = { min: number; q1: number; med: number; q3: number; max: number };
export default function ChartAdvancedBoxplotQuartileWhisker() {
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 CALC_W = 480, CALC_H = 300;
const LOOP_MS = 800;
const T0 = 200;
function clamp(v: number, a: number, b: number) { return Math.max(a, Math.min(b, v)); }
const rand = seedRand(425051);
function sample(n: number) {
const out: number[] = [];
for (let i = 0; i < n; i += 1) out.push(20 + rand() * 80);
return out;
}
const GROUP_A_1 = sample(9), GROUP_A_2 = sample(9);
const GROUP_B_1 = sample(9), GROUP_B_2 = sample(9);
function quartiles(arr: number[]): Stats {
const s = arr.slice().sort(function (a, b) { return a - b; });
function pct(p: number) {
const idx = (s.length - 1) * p, lo = Math.floor(idx), hi = Math.ceil(idx);
return s[lo] + (s[hi] - s[lo]) * (idx - lo);
}
return { min: s[0], q1: pct(0.25), med: pct(0.5), q3: pct(0.75), max: s[s.length - 1] };
}
const qA1 = quartiles(GROUP_A_1), qA2 = quartiles(GROUP_A_2);
const qB1 = quartiles(GROUP_B_1), qB2 = quartiles(GROUP_B_2);
function lerp(x0: number, x1: number, f: number) { return x0 + (x1 - x0) * f; }
function lerpStats(s1: Stats, s2: Stats, f: number): Stats {
return {
min: lerp(s1.min, s2.min, f),
q1: lerp(s1.q1, s2.q1, f),
med: lerp(s1.med, s2.med, f),
q3: lerp(s1.q3, s2.q3, f),
max: lerp(s1.max, s2.max, f)
};
}
const DOMAIN_MIN = 10, DOMAIN_MAX = 115;
const PLOT_TOP = 22, PLOT_BOTTOM = 292;
function valueToY(v: number) {
const f = (v - DOMAIN_MIN) / (DOMAIN_MAX - DOMAIN_MIN);
return PLOT_BOTTOM - f * (PLOT_BOTTOM - PLOT_TOP);
}
const BOX_FILL = ["47,109,246", "43,157,116"];
const CREAM_LINE = "255,247,230";
const BOX_WIDTH = 172;
const GROUP_X = [134, 346];
const OUTLIER_MULT = 1.2;
let cw = 480, ch = 300;
function drawBox(cx: number, stats: Stats, fillRgb: string, sx: number, sy: number, showOutlier: boolean) {
const yMin = valueToY(stats.min), yQ1 = valueToY(stats.q1);
const yMed = valueToY(stats.med), yQ3 = valueToY(stats.q3), yMax = valueToY(stats.max);
const xLeft = cx - BOX_WIDTH / 2;
const capHalf = BOX_WIDTH * 0.22;
ctx!.strokeStyle = "rgba(" + CREAM_LINE + ",.9)";
ctx!.lineWidth = 2;
ctx!.beginPath();
ctx!.moveTo(cx * sx, yMax * sy); ctx!.lineTo(cx * sx, yQ3 * sy); ctx!.stroke();
ctx!.beginPath();
ctx!.moveTo(cx * sx, yQ1 * sy); ctx!.lineTo(cx * sx, yMin * sy); ctx!.stroke();
ctx!.beginPath();
ctx!.moveTo((cx - capHalf) * sx, yMax * sy); ctx!.lineTo((cx + capHalf) * sx, yMax * sy); ctx!.stroke();
ctx!.beginPath();
ctx!.moveTo((cx - capHalf) * sx, yMin * sy); ctx!.lineTo((cx + capHalf) * sx, yMin * sy); ctx!.stroke();
ctx!.fillStyle = "rgba(" + fillRgb + ",.7)";
ctx!.fillRect(xLeft * sx, yQ3 * sy, BOX_WIDTH * sx, (yQ1 - yQ3) * sy);
ctx!.strokeStyle = "rgba(" + CREAM_LINE + ",.9)";
ctx!.strokeRect(xLeft * sx, yQ3 * sy, BOX_WIDTH * sx, (yQ1 - yQ3) * sy);
ctx!.beginPath();
ctx!.moveTo(xLeft * sx, yMed * sy);
ctx!.lineTo((xLeft + BOX_WIDTH) * sx, yMed * sy);
ctx!.lineWidth = 2.4;
ctx!.stroke();
if (showOutlier) {
const yOut = valueToY(stats.max * OUTLIER_MULT);
ctx!.beginPath();
ctx!.fillStyle = "rgba(" + CREAM_LINE + ",.95)";
ctx!.arc(cx * sx, yOut * sy, 4.4, 0, Math.PI * 2);
ctx!.fill();
ctx!.strokeStyle = "rgba(" + fillRgb + ",.95)";
ctx!.lineWidth = 1.6;
ctx!.stroke();
}
}
function draw(phase: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
const t = Math.max(0, phase - T0);
const half = (LOOP_MS - T0) / 2;
let blend = t < half ? t / half : 2 - t / half;
blend = clamp(blend, 0, 1);
const statsA = lerpStats(qA1, qA2, blend);
const statsB = lerpStats(qB1, qB2, blend);
drawBox(GROUP_X[0], statsA, BOX_FILL[0], sx, sy, blend > 0.62);
drawBox(GROUP_X[1], statsB, BOX_FILL[1], sx, sy, blend < 0.38);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function phaseOf(ms: number) { return reduce ? LOOP_MS * 0.5 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bxw} is-demo`} ref={rootRef}>
<canvas className={styles.bxw__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bxw__caption}>값들을 정렬해 사분위 경계를 계산한 뒤 상자 높이와 수염 길이로 옮겨 그려, 중앙값과 흩어진 정도가 한 도형에 담긴다.</p>
</div>
</main>
);
}05 · 四分位数を箱とひげで描くボックスプロット — 計測 累積面積 12.065% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var GROUP_A_1 = sample(9), GROUP_A_2 = sample(9);
var GROUP_B_1 = sample(9), GROUP_B_2 = sample(9);
function quartiles(arr) {
var s = arr.slice().sort(function (a, b) { return a - b; });
function pct(p) {
var idx = (s.length - 1) * p, lo = Math.floor(idx), hi = Math.ceil(idx);
return s[lo] + (s[hi] - s[lo]) * (idx - lo);
}
return { min: s[0], q1: pct(0.25), med: pct(0.5), q3: pct(0.75), max: s[s.length - 1] };
}
var qA1 = quartiles(GROUP_A_1), qA2 = quartiles(GROUP_A_2);
var qB1 = quartiles(GROUP_B_1), qB2 = quartiles(GROUP_B_2);
function lerp(x0, x1, f) { return x0 + (x1 - x0) * f; }
function lerpStats(s1, s2, f) {
return {
min: lerp(s1.min, s2.min, f),
q1: lerp(s1.q1, s2.q1, f),
med: lerp(s1.med, s2.med, f),
q3: lerp(s1.q3, s2.q3, f),
max: lerp(s1.max, s2.max, f)
};
}
05番は累積面積12.1%・平均強度128.2、20/23 フレームで動きました。
06複数の軸を横切る線で値をつなぐ平行座標チャート
一つの項目が持つ複数の値を各縦軸上の座標として計算し線分でつなぎ、軸の一つをドラッグでなぞるとその区間を通る線だけが強調されます。多変量の商品スペック比較表に向いていて、この9つの中で09番と並んでArrowキー入力にも反応する2つのうちの一つでもあります。

<main class="stage">
<div class="pcb is-demo" id="pcbRoot">
<canvas class="pcb__canvas" id="pcbCanvas" width="480" height="300"></canvas>
<div class="pcb__kbd" id="pcbKbd" tabindex="0" aria-label="ブラシ移動"></div>
<p class="pcb__caption">1つの項目が持つ複数の値をそれぞれの縦軸上の座標として計算し線分でつなぎ、軸の1本をドラッグして動かすと、その範囲を通る線だけが強調される</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pcb {
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-paper;
}
.pcb__canvas {
display: block;
width: 100%;
height: 100%;
}
.pcb__kbd {
position: absolute;
inset: 0;
background: transparent;
outline-offset: -2px;
pointer-events: none;
}
.pcb__kbd:focus-visible {
outline: 2px solid $subject-blue-light;
}
.pcb__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, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.pcb__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pcb { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pcb {
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: #ffffff;
}
.pcb__canvas {
display: block;
width: 100%;
height: 100%;
}
.pcb__kbd {
position: absolute;
inset: 0;
background: transparent;
outline-offset: -2px;
pointer-events: none;
}
.pcb__kbd:focus-visible {
outline: 2px solid #5b8cf8;
}
.pcb__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.pcb__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pcb {
cursor: default;
}
}(function () {
// 기법: 축마다 0~1 로 정규화된 값을 세로 좌표로 계산해 인접 축을 선분으로 이어 병렬좌표를
// 그리고, 가운데 축 위의 브러시 구간(포인터 드래그 · 화살표 키)에 걸리는 값을 가진 선만
// 강조 색으로 두껍게, 그렇지 않은 선은 흐리게 그린다. 매 프레임 전체 데이터를 다시 그리는
// 방식이라 별도의 상태 저장 없이 브러시 위치 하나만 갱신하면 된다.
var root = document.getElementById('pcbRoot');
var canvas = document.getElementById('pcbCanvas');
var kbdEl = document.getElementById('pcbKbd');
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(425061);
var AXIS_COUNT = 5, ITEM_COUNT = 6;
var DATA = [];
for (var di = 0; di < ITEM_COUNT; di += 1) {
var row = [];
for (var da = 0; da < AXIS_COUNT; da += 1) row.push(rand());
DATA.push(row);
}
var BRUSH_AXIS_INDEX = 2;
var BRUSH_HALF = 0.09;
var brushCenter = 0.5;
var dragging = false;
var lastUserInputAt = -9999;
var LOOP_MS = 2000;
var T0 = 200;
var cw = 480, ch = 300;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function axisX(a) {
var padX = cw * 0.09;
return padX + (cw - padX * 2) * (a / (AXIS_COUNT - 1));
}
function valueY(v) {
var padY = ch * 0.14;
return ch - padY - (ch - padY * 2) * v;
}
function yToValue(y) {
var padY = ch * 0.14;
return clamp(1 - (y - padY) / (ch - padY * 2), 0, 1);
}
function render() {
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(23,20,26,.22)';
ctx.lineWidth = 1;
for (var a = 0; a < AXIS_COUNT; a += 1) {
var x = axisX(a);
ctx.beginPath();
ctx.moveTo(x, valueY(0));
ctx.lineTo(x, valueY(1));
ctx.stroke();
}
var lo = brushCenter - BRUSH_HALF, hi = brushCenter + BRUSH_HALF;
var yLo = valueY(lo), yHi = valueY(hi);
ctx.fillStyle = 'rgba(91,140,248,.16)';
ctx.fillRect(0, yHi, cw, yLo - yHi);
for (var it = 0; it < ITEM_COUNT; it += 1) {
var v = DATA[it][BRUSH_AXIS_INDEX];
var inBand = v >= lo && v <= hi;
ctx.strokeStyle = inBand ? '#2f6df6' : 'rgba(23,20,26,.22)';
ctx.lineWidth = inBand ? 2.5 : 1;
ctx.beginPath();
for (var a2 = 0; a2 < AXIS_COUNT; a2 += 1) {
var px = axisX(a2), py = valueY(DATA[it][a2]);
if (a2 === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
}
}
function setBrushCenter(v) {
brushCenter = clamp(v, BRUSH_HALF, 1 - BRUSH_HALF);
render();
}
canvas.addEventListener('pointerdown', function (e) {
var rect = canvas.getBoundingClientRect();
var x = (e.clientX - rect.left) * (cw / rect.width);
var axisPx = axisX(BRUSH_AXIS_INDEX);
if (Math.abs(x - axisPx) < cw * 0.14) {
dragging = true;
var y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
}
});
canvas.addEventListener('pointermove', function (e) {
if (!dragging) return;
var rect = canvas.getBoundingClientRect();
var y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
});
canvas.addEventListener('pointerup', function () { dragging = false; });
canvas.addEventListener('pointerleave', function () { dragging = false; });
kbdEl.addEventListener('keydown', function (e) {
e.preventDefault();
if (e.key === 'ArrowUp') { lastUserInputAt = elapsed; setBrushCenter(brushCenter + 0.04); }
else if (e.key === 'ArrowDown') { lastUserInputAt = elapsed; setBrushCenter(brushCenter - 0.04); }
});
function autoDemoStep(elapsedMs) {
if (elapsedMs < T0) return;
if (elapsedMs - lastUserInputAt < 800) return;
var t = elapsedMs % LOOP_MS;
setBrushCenter(0.5 + Math.sin((t - T0) / LOOP_MS * Math.PI * 2) * 0.35);
}
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;
render();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var elapsed = 0;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
autoDemoStep(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedParallelCoordinatesBrush.module.scss";
export default function ChartAdvancedParallelCoordinatesBrush() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const kbdRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const kbdEl = kbdRef.current;
if (!root || !canvas || !kbdEl) 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(425061);
const AXIS_COUNT = 5, ITEM_COUNT = 6;
const DATA: number[][] = [];
for (let di = 0; di < ITEM_COUNT; di += 1) {
const row: number[] = [];
for (let da = 0; da < AXIS_COUNT; da += 1) row.push(rand());
DATA.push(row);
}
const BRUSH_AXIS_INDEX = 2;
const BRUSH_HALF = 0.09;
let brushCenter = 0.5;
let dragging = false;
let lastUserInputAt = -9999;
const LOOP_MS = 2000;
const T0 = 200;
let cw = 480, ch = 300;
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
function axisX(a: number) {
const padX = cw * 0.09;
return padX + (cw - padX * 2) * (a / (AXIS_COUNT - 1));
}
function valueY(v: number) {
const padY = ch * 0.14;
return ch - padY - (ch - padY * 2) * v;
}
function yToValue(y: number) {
const padY = ch * 0.14;
return clamp(1 - (y - padY) / (ch - padY * 2), 0, 1);
}
function render() {
ctx!.clearRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(23,20,26,.22)";
ctx!.lineWidth = 1;
for (let a = 0; a < AXIS_COUNT; a += 1) {
const x = axisX(a);
ctx!.beginPath();
ctx!.moveTo(x, valueY(0));
ctx!.lineTo(x, valueY(1));
ctx!.stroke();
}
const lo = brushCenter - BRUSH_HALF, hi = brushCenter + BRUSH_HALF;
const yLo = valueY(lo), yHi = valueY(hi);
ctx!.fillStyle = "rgba(91,140,248,.16)";
ctx!.fillRect(0, yHi, cw, yLo - yHi);
for (let it = 0; it < ITEM_COUNT; it += 1) {
const v = DATA[it][BRUSH_AXIS_INDEX];
const inBand = v >= lo && v <= hi;
ctx!.strokeStyle = inBand ? "#2f6df6" : "rgba(23,20,26,.22)";
ctx!.lineWidth = inBand ? 2.5 : 1;
ctx!.beginPath();
for (let a2 = 0; a2 < AXIS_COUNT; a2 += 1) {
const px = axisX(a2), py = valueY(DATA[it][a2]);
if (a2 === 0) ctx!.moveTo(px, py); else ctx!.lineTo(px, py);
}
ctx!.stroke();
}
}
function setBrushCenter(v: number) {
brushCenter = clamp(v, BRUSH_HALF, 1 - BRUSH_HALF);
render();
}
function onPointerDown(e: PointerEvent) {
const rect = canvas!.getBoundingClientRect();
const x = (e.clientX - rect.left) * (cw / rect.width);
const axisPx = axisX(BRUSH_AXIS_INDEX);
if (Math.abs(x - axisPx) < cw * 0.14) {
dragging = true;
const y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
}
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const rect = canvas!.getBoundingClientRect();
const y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
}
function onPointerUp() { dragging = false; }
function onPointerLeave() { dragging = false; }
function onKeyDown(e: KeyboardEvent) {
e.preventDefault();
if (e.key === "ArrowUp") { lastUserInputAt = elapsed; setBrushCenter(brushCenter + 0.04); }
else if (e.key === "ArrowDown") { lastUserInputAt = elapsed; setBrushCenter(brushCenter - 0.04); }
}
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointerleave", onPointerLeave);
kbdEl.addEventListener("keydown", onKeyDown);
function autoDemoStep(elapsedMs: number) {
if (elapsedMs < T0) return;
if (elapsedMs - lastUserInputAt < 800) return;
const t = elapsedMs % LOOP_MS;
setBrushCenter(0.5 + Math.sin((t - T0) / LOOP_MS * Math.PI * 2) * 0.35);
}
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;
render();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let elapsed = 0;
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;
autoDemoStep(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointerleave", onPointerLeave);
kbdEl.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pcb} is-demo`} ref={rootRef}>
<canvas className={styles.pcb__canvas} ref={canvasRef} width={480} height={300} />
<div className={styles.pcb__kbd} ref={kbdRef} tabIndex={0} aria-label="브러시 이동" />
<p className={styles.pcb__caption}>항목 하나의 여러 값을 각 세로축 위 좌표로 계산해 선분으로 잇고, 축 하나를 드래그로 훑으면 그 구간을 지나는 선만 강조된다</p>
</div>
</main>
);
}06 · 複数の軸を横切る線で値をつなぐ平行座標チャート — 計測 累積面積 46.867% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function setBrushCenter(v) {
brushCenter = clamp(v, BRUSH_HALF, 1 - BRUSH_HALF);
render();
}
canvas.addEventListener('pointerdown', function (e) {
var rect = canvas.getBoundingClientRect();
var x = (e.clientX - rect.left) * (cw / rect.width);
var axisPx = axisX(BRUSH_AXIS_INDEX);
if (Math.abs(x - axisPx) < cw * 0.14) {
dragging = true;
var y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
}
});
canvas.addEventListener('pointermove', function (e) {
if (!dragging) return;
var rect = canvas.getBoundingClientRect();
var y = (e.clientY - rect.top) * (ch / rect.height);
lastUserInputAt = elapsed;
setBrushCenter(yToValue(y));
});
canvas.addEventListener('pointerup', function () { dragging = false; });
canvas.addEventListener('pointerleave', function () { dragging = false; });
06番は累積面積46.9%・平均強度33.6と9つの中で最も広い面積が動き、21/23 フレームで動きました——ブラシをドラッグするたびキャンバス上の全ての線が同時に再描画されるためです。
07値ごとに半径が異なる花びらで広がるコクスコムチャート
項目数ぶん角度を均等に分け扇形を描き、値の大きさを半径に変換することで、同じ幅でも値が大きいほど花びらが外側まで伸びます。月別繰り返し指標比較のダッシュボードカードのように、各カテゴリの見た目の重みを揃えたまま長さだけで比較したい場面に向いています。

<main class="stage">
<div class="cxc is-demo" id="cxcRoot">
<canvas class="cxc__canvas" id="cxcCanvas" width="480" height="300"></canvas>
<p class="cxc__caption">項目数に応じて角度を分け扇形を描き、値の大きさを半径に換算することで、同じ幅でも値が大きいほど花びらが外側まで伸びる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.cxc {
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-paper;
}
.cxc__canvas {
display: block;
width: 100%;
height: 100%;
}
.cxc__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(23, 20, 26, .82);
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.cxc__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cxc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cxc {
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: #ffffff;
}
.cxc__canvas {
display: block;
width: 100%;
height: 100%;
}
.cxc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.cxc__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cxc {
cursor: default;
}
}(function () {
// 기법: 항목 수만큼 중심각을 똑같이 나눠 부채꼴(꽃잎) 자리를 만들고, 값의 크기를
// 반지름 목표치로 환산한다. 매 프레임 꽃잎마다 다른 시작 지연을 둔 완화 곡선으로
// 반지름을 0에서 목표까지 실제로 다시 계산해 그려 순차로 자라나는 과정을 보여준다.
var root = document.getElementById('cxcRoot');
var canvas = document.getElementById('cxcCanvas');
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 CALC_W = 480, CALC_H = 300;
var LOOP_MS = 2000;
var rand = seedRand(425071);
var COUNT = 6;
var VALUES = [];
for (var vi = 0; vi < COUNT; vi += 1) VALUES.push(20 + rand() * 80);
var COXCOMB_PETAL_GAP_DEG = 2;
var anglePerPetal = (360 - COXCOMB_PETAL_GAP_DEG * COUNT) / COUNT;
var MIN_R = 18, MAX_R = 130;
var maxValue = Math.max.apply(null, VALUES);
var targetRadii = VALUES.map(function (v) { return MIN_R + (v / maxValue) * (MAX_R - MIN_R); });
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); }
function easeInCubic(x) { return x * x * x; }
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
var CENTER_X = CALC_W * 0.5, CENTER_Y = CALC_H * 0.5;
var FILL_COLORS = ['47,109,246', '194,54,26', '43,157,116', '138,92,214'];
var INK_STROKE = '23,20,26';
function radiusAt(i, t) {
var target = targetRadii[i];
if (t < 1400) {
var growProgress = clamp01((t - i * 90) / 500);
return target * easeOutCubic(growProgress);
}
if (t < 1750) return target;
var shrinkProgress = clamp01((t - 1750) / 200);
return target * (1 - easeInCubic(shrinkProgress));
}
var cw = 480, ch = 300;
function draw(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var scale = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
var cx = CENTER_X * sx, cy = CENTER_Y * sy;
var startDeg = -90;
for (var i = 0; i < COUNT; i += 1) {
var r = radiusAt(i, t) * scale;
var a0 = startDeg * Math.PI / 180;
var a1 = (startDeg + anglePerPetal) * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, a0, a1);
ctx.closePath();
ctx.fillStyle = 'rgba(' + FILL_COLORS[i % 4] + ',.75)';
ctx.fill();
ctx.strokeStyle = 'rgba(' + INK_STROKE + ',.9)';
ctx.lineWidth = 1;
ctx.stroke();
startDeg += anglePerPetal + COXCOMB_PETAL_GAP_DEG;
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function phaseOf(ms) { return reduce ? LOOP_MS * 0.72 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(phaseOf(elapsed));
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedCoxcombRadialPetal.module.scss";
export default function ChartAdvancedCoxcombRadialPetal() {
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 CALC_W = 480, CALC_H = 300;
const LOOP_MS = 2000;
const rand = seedRand(425071);
const COUNT = 6;
const VALUES: number[] = [];
for (let vi = 0; vi < COUNT; vi += 1) VALUES.push(20 + rand() * 80);
const COXCOMB_PETAL_GAP_DEG = 2;
const anglePerPetal = (360 - COXCOMB_PETAL_GAP_DEG * COUNT) / COUNT;
const MIN_R = 18, MAX_R = 130;
const maxValue = Math.max.apply(null, VALUES);
const targetRadii = VALUES.map(function (v) { return MIN_R + (v / maxValue) * (MAX_R - MIN_R); });
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
function easeInCubic(x: number) { return x * x * x; }
function clamp01(x: number) { return Math.max(0, Math.min(1, x)); }
const CENTER_X = CALC_W * 0.5, CENTER_Y = CALC_H * 0.5;
const FILL_COLORS = ["47,109,246", "194,54,26", "43,157,116", "138,92,214"];
const INK_STROKE = "23,20,26";
function radiusAt(i: number, t: number) {
const target = targetRadii[i];
if (t < 1400) {
const growProgress = clamp01((t - i * 90) / 500);
return target * easeOutCubic(growProgress);
}
if (t < 1750) return target;
const shrinkProgress = clamp01((t - 1750) / 200);
return target * (1 - easeInCubic(shrinkProgress));
}
let cw = 480, ch = 300;
function draw(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const scale = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
const cx = CENTER_X * sx, cy = CENTER_Y * sy;
let startDeg = -90;
for (let i = 0; i < COUNT; i += 1) {
const r = radiusAt(i, t) * scale;
const a0 = startDeg * Math.PI / 180;
const a1 = (startDeg + anglePerPetal) * Math.PI / 180;
ctx!.beginPath();
ctx!.moveTo(cx, cy);
ctx!.arc(cx, cy, r, a0, a1);
ctx!.closePath();
ctx!.fillStyle = "rgba(" + FILL_COLORS[i % 4] + ",.75)";
ctx!.fill();
ctx!.strokeStyle = "rgba(" + INK_STROKE + ",.9)";
ctx!.lineWidth = 1;
ctx!.stroke();
startDeg += anglePerPetal + COXCOMB_PETAL_GAP_DEG;
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function phaseOf(ms: number) { return reduce ? LOOP_MS * 0.72 : (ms % LOOP_MS); }
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(phaseOf(elapsed));
}
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(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(phaseOf(elapsed));
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.cxc} is-demo`} ref={rootRef}>
<canvas className={styles.cxc__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.cxc__caption}>항목 수만큼 각도를 나눠 부채꼴을 그리고 값의 크기를 반지름으로 환산해, 같은 폭이라도 값이 클수록 꽃잎이 바깥까지 뻗는다.</p>
</div>
</main>
);
}07 · 値ごとに半径が異なる花びらで広がるコクスコムチャート — 計測 累積面積 19.5% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var COXCOMB_PETAL_GAP_DEG = 2;
var anglePerPetal = (360 - COXCOMB_PETAL_GAP_DEG * COUNT) / COUNT;
var MIN_R = 18, MAX_R = 130;
var maxValue = Math.max.apply(null, VALUES);
var targetRadii = VALUES.map(function (v) { return MIN_R + (v / maxValue) * (MAX_R - MIN_R); });
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); }
function easeInCubic(x) { return x * x * x; }
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
var CENTER_X = CALC_W * 0.5, CENTER_Y = CALC_H * 0.5;
var FILL_COLORS = ['47,109,246', '194,54,26', '43,157,116', '138,92,214'];
var INK_STROKE = '23,20,26';
function radiusAt(i, t) {
var target = targetRadii[i];
if (t < 1400) {
var growProgress = clamp01((t - i * 90) / 500);
return target * easeOutCubic(growProgress);
}
if (t < 1750) return target;
var shrinkProgress = clamp01((t - 1750) / 200);
return target * (1 - easeInCubic(shrinkProgress));
}
07番は累積面積19.5%・平均強度137.9、17/23 フレームで動きました。
08順位が入れ替わると線が交差するバンプチャート
区間ごとに値の順序を付け直し順位を縦座標として計算し、二つの項目の順位が入れ替わる地点で線同士が交差します。四半期ごとの人気商品順位変動表に向いていて、線が交わる地点そのものが伝えたい情報になります。

<main class="stage">
<div class="bmp is-demo" id="bmpRoot">
<canvas class="bmp__canvas" id="bmpCanvas" width="480" height="300"></canvas>
<p class="bmp__caption">区間ごとに値の順位を付け直し、その順位を縦座標として計算するため、2つの項目の順位が入れ替わる地点で線同士が交差する</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.bmp {
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-paper;
}
.bmp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bmp__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, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bmp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bmp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bmp {
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: #ffffff;
}
.bmp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bmp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bmp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bmp {
cursor: default;
}
}(function () {
// 기법: 기간마다 값을 다시 정렬해 순위(등수)를 매기고, 그 순위를 세로 좌표로 써서 항목별
// 꺾은선을 그린다. 두 항목의 순위가 인접 구간에서 뒤바뀌면 두 직선이 그 구간 안에서
// 반드시 교차하므로, 교차점을 대수적으로 계산해 순간적으로 강조한다. 왼쪽 구간부터
// 순서대로 공개(reveal)해 시간이 흐르며 그려지는 것처럼 보이게 한다.
var root = document.getElementById('bmpRoot');
var canvas = document.getElementById('bmpCanvas');
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(425081);
var ITEM_COUNT = 5, PERIOD_COUNT = 5;
var VALUES = [];
for (var p = 0; p < PERIOD_COUNT; p += 1) {
var row = [];
for (var i = 0; i < ITEM_COUNT; i += 1) row.push(10 + rand() * 90);
VALUES.push(row);
}
function ranksOf(row) {
var idx = row.map(function (v, i) { return i; });
idx.sort(function (a, b) { return row[b] - row[a]; });
var rank = new Array(row.length);
idx.forEach(function (itemI, pos) { rank[itemI] = pos; });
return rank;
}
var RANKS = VALUES.map(ranksOf);
var LINE_COLORS = ['#2f6df6', '#c2361a', '#2b9d74', '#8a5cd6', '#0a4fe8'];
var LOOP_MS = 2000;
var T0 = 200;
var SEG_MS = 300;
var SEG_DUR = 260;
var SEG_COUNT = PERIOD_COUNT - 1;
var cw = 480, ch = 300;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function periodX(p) {
var padX = cw * 0.08;
return padX + (cw - padX * 2) * (p / (PERIOD_COUNT - 1));
}
function rankY(r) {
var padY = ch * 0.14;
return padY + (ch - padY * 2) * (r / (ITEM_COUNT - 1));
}
function render(elapsedMs) {
var t = elapsedMs % LOOP_MS;
ctx.clearRect(0, 0, cw, ch);
var fadeAlpha = 1;
if (t >= 1750) fadeAlpha = clamp(1 - (t - 1750) / 200, 0, 1);
ctx.globalAlpha = fadeAlpha;
var rowH = (ch - ch * 0.28) / (ITEM_COUNT - 1);
var crossings = [];
for (var s = 0; s < SEG_COUNT; s += 1) {
var raw = (t - T0 - s * SEG_MS) / SEG_DUR;
if (raw < 0) continue;
var flashing = raw >= 0.9 && raw <= 1.2;
if (!flashing) continue;
for (var a = 0; a < ITEM_COUNT; a += 1) {
for (var b = a + 1; b < ITEM_COUNT; b += 1) {
var y1a = RANKS[s][a], y1b = RANKS[s + 1][a];
var y2a = RANKS[s][b], y2b = RANKS[s + 1][b];
var Da = y1a - y2a, Db = y1b - y2b;
if ((Da > 0) === (Db > 0)) continue;
var f = Da / (Da - Db);
if (f < 0 || f > 1) continue;
crossings.push({ s: s, f: f, rank: y1a + (y1b - y1a) * f });
}
}
}
for (var r = 0; r < ITEM_COUNT; r += 1) {
var boosted = crossings.some(function (c) { return Math.round(c.rank) === r; });
ctx.fillStyle = boosted ? 'rgba(23,20,26,.14)' : 'rgba(23,20,26,.05)';
var bandTop = Math.max(0, rankY(r) - rowH / 2);
var bandBottom = Math.min(ch, rankY(r) + rowH / 2);
ctx.fillRect(0, bandTop, cw, bandBottom - bandTop);
}
for (var it = 0; it < ITEM_COUNT; it += 1) {
ctx.strokeStyle = LINE_COLORS[it];
ctx.lineWidth = 2.5;
for (var s2 = 0; s2 < SEG_COUNT; s2 += 1) {
var raw2 = (t - T0 - s2 * SEG_MS) / SEG_DUR;
if (raw2 <= 0) break;
var frac2 = clamp(raw2, 0, 1);
var x0 = periodX(s2), x1 = periodX(s2 + 1);
var y0 = rankY(RANKS[s2][it]), y1v = rankY(RANKS[s2 + 1][it]);
var xEnd = x0 + (x1 - x0) * frac2;
var yEnd = y0 + (y1v - y0) * frac2;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
}
for (var pd = 0; pd < PERIOD_COUNT; pd += 1) {
var arrived = pd === 0 || clamp((t - T0 - (pd - 1) * SEG_MS) / SEG_DUR, 0, 1) >= 1;
if (!arrived) continue;
ctx.fillStyle = LINE_COLORS[it];
ctx.beginPath();
ctx.arc(periodX(pd), rankY(RANKS[pd][it]), 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
for (var ci = 0; ci < crossings.length; ci += 1) {
var c = crossings[ci];
var cx0 = periodX(c.s), cx1 = periodX(c.s + 1);
var xc = cx0 + (cx1 - cx0) * c.f;
var yc = rankY(c.rank);
ctx.strokeStyle = '#17141a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(xc, yc, 5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function autoDemoStep(elapsedMs) {
render(elapsedMs);
}
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;
render(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = reduce ? 1500 : 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;
autoDemoStep(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedBumpRankCrossing.module.scss";
type Crossing = { s: number; f: number; rank: number };
export default function ChartAdvancedBumpRankCrossing() {
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(425081);
const ITEM_COUNT = 5, PERIOD_COUNT = 5;
const VALUES: number[][] = [];
for (let p = 0; p < PERIOD_COUNT; p += 1) {
const row: number[] = [];
for (let i = 0; i < ITEM_COUNT; i += 1) row.push(10 + rand() * 90);
VALUES.push(row);
}
function ranksOf(row: number[]) {
const idx = row.map((v, i) => i);
idx.sort((a, b) => row[b] - row[a]);
const rank = new Array(row.length);
idx.forEach((itemI, pos) => { rank[itemI] = pos; });
return rank;
}
const RANKS = VALUES.map(ranksOf);
const LINE_COLORS = ["#2f6df6", "#c2361a", "#2b9d74", "#8a5cd6", "#0a4fe8"];
const LOOP_MS = 2000;
const T0 = 200;
const SEG_MS = 300;
const SEG_DUR = 260;
const SEG_COUNT = PERIOD_COUNT - 1;
let cw = 480, ch = 300;
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
function periodX(p: number) {
const padX = cw * 0.08;
return padX + (cw - padX * 2) * (p / (PERIOD_COUNT - 1));
}
function rankY(r: number) {
const padY = ch * 0.14;
return padY + (ch - padY * 2) * (r / (ITEM_COUNT - 1));
}
function render(elapsedMs: number) {
const t = elapsedMs % LOOP_MS;
ctx!.clearRect(0, 0, cw, ch);
let fadeAlpha = 1;
if (t >= 1750) fadeAlpha = clamp(1 - (t - 1750) / 200, 0, 1);
ctx!.globalAlpha = fadeAlpha;
const rowH = (ch - ch * 0.28) / (ITEM_COUNT - 1);
const crossings: Crossing[] = [];
for (let s = 0; s < SEG_COUNT; s += 1) {
const raw = (t - T0 - s * SEG_MS) / SEG_DUR;
if (raw < 0) continue;
const flashing = raw >= 0.9 && raw <= 1.2;
if (!flashing) continue;
for (let a = 0; a < ITEM_COUNT; a += 1) {
for (let b = a + 1; b < ITEM_COUNT; b += 1) {
const y1a = RANKS[s][a], y1b = RANKS[s + 1][a];
const y2a = RANKS[s][b], y2b = RANKS[s + 1][b];
const Da = y1a - y2a, Db = y1b - y2b;
if ((Da > 0) === (Db > 0)) continue;
const f = Da / (Da - Db);
if (f < 0 || f > 1) continue;
crossings.push({ s: s, f: f, rank: y1a + (y1b - y1a) * f });
}
}
}
for (let r = 0; r < ITEM_COUNT; r += 1) {
const boosted = crossings.some((c) => Math.round(c.rank) === r);
ctx!.fillStyle = boosted ? "rgba(23,20,26,.14)" : "rgba(23,20,26,.05)";
const bandTop = Math.max(0, rankY(r) - rowH / 2);
const bandBottom = Math.min(ch, rankY(r) + rowH / 2);
ctx!.fillRect(0, bandTop, cw, bandBottom - bandTop);
}
for (let it = 0; it < ITEM_COUNT; it += 1) {
ctx!.strokeStyle = LINE_COLORS[it];
ctx!.lineWidth = 2.5;
for (let s2 = 0; s2 < SEG_COUNT; s2 += 1) {
const raw2 = (t - T0 - s2 * SEG_MS) / SEG_DUR;
if (raw2 <= 0) break;
const frac2 = clamp(raw2, 0, 1);
const x0 = periodX(s2), x1 = periodX(s2 + 1);
const y0 = rankY(RANKS[s2][it]), y1v = rankY(RANKS[s2 + 1][it]);
const xEnd = x0 + (x1 - x0) * frac2;
const yEnd = y0 + (y1v - y0) * frac2;
ctx!.beginPath();
ctx!.moveTo(x0, y0);
ctx!.lineTo(xEnd, yEnd);
ctx!.stroke();
}
for (let pd = 0; pd < PERIOD_COUNT; pd += 1) {
const arrived = pd === 0 || clamp((t - T0 - (pd - 1) * SEG_MS) / SEG_DUR, 0, 1) >= 1;
if (!arrived) continue;
ctx!.fillStyle = LINE_COLORS[it];
ctx!.beginPath();
ctx!.arc(periodX(pd), rankY(RANKS[pd][it]), 3.5, 0, Math.PI * 2);
ctx!.fill();
}
}
for (let ci = 0; ci < crossings.length; ci += 1) {
const c = crossings[ci];
const cx0 = periodX(c.s), cx1 = periodX(c.s + 1);
const xc = cx0 + (cx1 - cx0) * c.f;
const yc = rankY(c.rank);
ctx!.strokeStyle = "#17141a";
ctx!.lineWidth = 1.5;
ctx!.beginPath();
ctx!.arc(xc, yc, 5, 0, Math.PI * 2);
ctx!.stroke();
}
ctx!.globalAlpha = 1;
}
function autoDemoStep(elapsedMs: number) {
render(elapsedMs);
}
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;
render(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = reduce ? 1500 : 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;
autoDemoStep(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bmp} is-demo`} ref={rootRef}>
<canvas className={styles.bmp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bmp__caption}>구간마다 값 순서를 다시 매겨 순위를 세로 좌표로 계산해, 두 항목의 순위가 뒤바뀌는 지점에서 선이 서로 교차한다</p>
</div>
</main>
);
}08 · 順位が入れ替わると線が交差するバンプチャート — 計測 累積面積 42.761% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var rand = seedRand(425081);
var ITEM_COUNT = 5, PERIOD_COUNT = 5;
var VALUES = [];
for (var p = 0; p < PERIOD_COUNT; p += 1) {
var row = [];
for (var i = 0; i < ITEM_COUNT; i += 1) row.push(10 + rand() * 90);
VALUES.push(row);
}
function ranksOf(row) {
var idx = row.map(function (v, i) { return i; });
idx.sort(function (a, b) { return row[b] - row[a]; });
var rank = new Array(row.length);
idx.forEach(function (itemI, pos) { rank[itemI] = pos; });
return rank;
}
var RANKS = VALUES.map(ranksOf);
var LINE_COLORS = ['#2f6df6', '#c2361a', '#2b9d74', '#8a5cd6', '#0a4fe8'];
var LOOP_MS = 2000;
var T0 = 200;
var SEG_MS = 300;
var SEG_DUR = 260;
var SEG_COUNT = PERIOD_COUNT - 1;
08番は累積面積42.8%・平均強度75.8、20/23 フレームで動きました。
09実体とひげで四本値を描くローソク足チャート
始値と終値の間を実体の四角形として、高値・安値までを細いひげ線として計算して描き、一本の棒にその日の四つの値が重なって収まります。コードで作る株価・為替の履歴パネルに向いていて、1本の足に4つの数値を同時に読ませられるのが強みになります。

<main class="stage">
<div class="cdl is-demo" id="cdlRoot">
<canvas class="cdl__canvas" id="cdlCanvas" width="480" height="300"></canvas>
<div class="cdl__kbd" id="cdlKbd" tabindex="0" aria-label="ローソク足を探索"></div>
<p class="cdl__caption">始値と終値の間を四角い実体として、高値・安値までを細いひげ線として計算して描き、1本のバーにその日の4つの値が重なって収められる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.cdl {
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;
}
.cdl__canvas {
display: block;
width: 100%;
height: 100%;
}
.cdl__kbd {
position: absolute;
inset: 0;
background: transparent;
outline-offset: -2px;
pointer-events: none;
}
.cdl__kbd:focus-visible {
outline: 2px solid $subject-blue-light;
}
.cdl__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) {
.cdl__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cdl { 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);
}
.cdl {
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;
}
.cdl__canvas {
display: block;
width: 100%;
height: 100%;
}
.cdl__kbd {
position: absolute;
inset: 0;
background: transparent;
outline-offset: -2px;
pointer-events: none;
}
.cdl__kbd:focus-visible {
outline: 2px solid #5b8cf8;
}
.cdl__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) {
.cdl__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cdl {
cursor: default;
}
}(function () {
// 기법: 시가·종가 사이를 몸통 사각형, 고가·저가 사이를 얇은 꼬리선으로 실제 값 좌표에 맞춰
// 계산해 그린다. 등장할 때는 값 위치는 고정한 채 중심에서부터 높이만 커지도록(진행도 스케일)
// 그려 부드럽게 나타나게 하고, 포인터·화살표 키로 특정 막대를 가리키면 크로스헤어와 함께
// 시가·고가·저가·종가 네 숫자를 칩에 표시한다.
var root = document.getElementById('cdlRoot');
var canvas = document.getElementById('cdlCanvas');
var kbdEl = document.getElementById('cdlKbd');
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(425091);
var CANDLE_COUNT = 10;
var candles = [];
var price = 100;
for (var i = 0; i < CANDLE_COUNT; i += 1) {
var open = price;
var vol = 4 + rand() * 10;
var close = open + (rand() - 0.5) * 2 * vol;
var high = Math.max(open, close) + rand() * vol * 0.6;
var low = Math.min(open, close) - rand() * vol * 0.6;
candles.push({ open: open, close: close, high: high, low: low });
price = close;
}
var scaleMin = Math.min.apply(null, candles.map(function (c) { return c.low; }));
var scaleMax = Math.max.apply(null, candles.map(function (c) { return c.high; }));
var CANDLE_REVEAL_STAGGER_MS = 110, CANDLE_REVEAL_DUR_MS = 160;
var hoverIndex = -1;
var lastUserInputAt = -9999;
var LOOP_MS = 2000;
var T0 = 200;
var ALL_DONE_MS = T0 + (CANDLE_COUNT - 1) * CANDLE_REVEAL_STAGGER_MS + CANDLE_REVEAL_DUR_MS;
var cw = 480, ch = 300;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); }
function candleX(idx) {
var padX = cw * 0.06;
var slot = (cw - padX * 2) / CANDLE_COUNT;
return padX + slot * (idx + 0.5);
}
function priceY(v) {
var padY = ch * 0.12;
return ch - padY - (ch - padY * 2) * (v - scaleMin) / (scaleMax - scaleMin);
}
function growP(idx, t) {
return clamp((t - T0 - idx * CANDLE_REVEAL_STAGGER_MS) / CANDLE_REVEAL_DUR_MS, 0, 1);
}
function nearestCandle(x) {
var best = 0, bestD = Infinity;
for (var k = 0; k < CANDLE_COUNT; k += 1) {
var d = Math.abs(candleX(k) - x);
if (d < bestD) { bestD = d; best = k; }
}
return best;
}
function render(elapsedMs) {
var t = elapsedMs % LOOP_MS;
ctx.clearRect(0, 0, cw, ch);
var padX = cw * 0.06;
var slot = (cw - padX * 2) / CANDLE_COUNT;
var bw = slot * 0.55;
for (var idx = 0; idx < CANDLE_COUNT; idx += 1) {
var c = candles[idx];
var grow = easeOutCubic(growP(idx, t));
var x = candleX(idx);
var wickTopFull = priceY(c.high), wickBotFull = priceY(c.low);
var wickCenter = (wickTopFull + wickBotFull) / 2;
var wickHalf = (wickBotFull - wickTopFull) / 2 * grow;
ctx.strokeStyle = '#9a93a6';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, wickCenter - wickHalf);
ctx.lineTo(x, wickCenter + wickHalf);
ctx.stroke();
var bodyTopFull = priceY(Math.max(c.open, c.close));
var bodyBotFull = priceY(Math.min(c.open, c.close));
var bodyCenter = (bodyTopFull + bodyBotFull) / 2;
var bodyHalf = Math.max(1, (bodyBotFull - bodyTopFull) / 2 * grow);
var up = c.close >= c.open;
ctx.fillStyle = up ? '#3ddc97' : '#ff7a5e';
ctx.fillRect(x - bw / 2, bodyCenter - bodyHalf, bw, bodyHalf * 2);
}
if (hoverIndex >= 0) {
var hc = candles[hoverIndex];
var hx = candleX(hoverIndex);
var hy = priceY(hc.close);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,.55)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(hx, 0); ctx.lineTo(hx, ch); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, hy); ctx.lineTo(cw, hy); ctx.stroke();
ctx.setLineDash([]);
var label = Math.round(hc.open) + '·' + Math.round(hc.high) + '·' + Math.round(hc.low) + '·' + Math.round(hc.close);
ctx.font = '11px sans-serif';
var padChip = 6;
var textW = ctx.measureText(label).width;
var chipW = textW + padChip * 2, chipH = 20;
var chipX = clamp(hx + 8, 4, cw - chipW - 4);
var chipY = Math.max(4, hy - chipH - 6);
ctx.fillStyle = '#231f28';
ctx.fillRect(chipX, chipY, chipW, chipH);
ctx.fillStyle = '#9a93a6';
ctx.textBaseline = 'middle';
ctx.fillText(label, chipX + padChip, chipY + chipH / 2);
}
}
function setHoverIndex(idx) {
hoverIndex = clamp(idx, -1, CANDLE_COUNT - 1);
render(elapsed);
}
canvas.addEventListener('pointermove', function (e) {
var rect = canvas.getBoundingClientRect();
var x = (e.clientX - rect.left) * (cw / rect.width);
lastUserInputAt = elapsed;
setHoverIndex(nearestCandle(x));
});
canvas.addEventListener('pointerleave', function () {
setHoverIndex(-1);
});
kbdEl.addEventListener('keydown', function (e) {
e.preventDefault();
if (e.key === 'ArrowLeft') {
lastUserInputAt = elapsed;
setHoverIndex(hoverIndex < 0 ? 0 : hoverIndex - 1);
} else if (e.key === 'ArrowRight') {
lastUserInputAt = elapsed;
setHoverIndex(hoverIndex < 0 ? 0 : hoverIndex + 1);
}
});
function autoDemoStep(elapsedMs) {
var t = elapsedMs % LOOP_MS;
if (elapsedMs - lastUserInputAt >= 800 && t >= ALL_DONE_MS) {
var span = Math.max(1, LOOP_MS - ALL_DONE_MS);
var frac = (t - ALL_DONE_MS) / span;
hoverIndex = Math.min(CANDLE_COUNT - 1, Math.floor(frac * CANDLE_COUNT));
}
render(elapsedMs);
}
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;
render(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = reduce ? ALL_DONE_MS + 50 : 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;
autoDemoStep(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ChartAdvancedCandlestickOhlcWick.module.scss";
type Candle = { open: number; close: number; high: number; low: number };
export default function ChartAdvancedCandlestickOhlcWick() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const kbdRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const kbdEl = kbdRef.current;
if (!root || !canvas || !kbdEl) 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(425091);
const CANDLE_COUNT = 10;
const candles: Candle[] = [];
let price = 100;
for (let i = 0; i < CANDLE_COUNT; i += 1) {
const open = price;
const vol = 4 + rand() * 10;
const close = open + (rand() - 0.5) * 2 * vol;
const high = Math.max(open, close) + rand() * vol * 0.6;
const low = Math.min(open, close) - rand() * vol * 0.6;
candles.push({ open: open, close: close, high: high, low: low });
price = close;
}
const scaleMin = Math.min.apply(null, candles.map((c) => c.low));
const scaleMax = Math.max.apply(null, candles.map((c) => c.high));
const CANDLE_REVEAL_STAGGER_MS = 110, CANDLE_REVEAL_DUR_MS = 160;
let hoverIndex = -1;
let lastUserInputAt = -9999;
const LOOP_MS = 2000;
const T0 = 200;
const ALL_DONE_MS = T0 + (CANDLE_COUNT - 1) * CANDLE_REVEAL_STAGGER_MS + CANDLE_REVEAL_DUR_MS;
let cw = 480, ch = 300;
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
function candleX(idx: number) {
const padX = cw * 0.06;
const slot = (cw - padX * 2) / CANDLE_COUNT;
return padX + slot * (idx + 0.5);
}
function priceY(v: number) {
const padY = ch * 0.12;
return ch - padY - (ch - padY * 2) * (v - scaleMin) / (scaleMax - scaleMin);
}
function growP(idx: number, t: number) {
return clamp((t - T0 - idx * CANDLE_REVEAL_STAGGER_MS) / CANDLE_REVEAL_DUR_MS, 0, 1);
}
function nearestCandle(x: number) {
let best = 0, bestD = Infinity;
for (let k = 0; k < CANDLE_COUNT; k += 1) {
const d = Math.abs(candleX(k) - x);
if (d < bestD) { bestD = d; best = k; }
}
return best;
}
function render(elapsedMs: number) {
const t = elapsedMs % LOOP_MS;
ctx!.clearRect(0, 0, cw, ch);
const padX = cw * 0.06;
const slot = (cw - padX * 2) / CANDLE_COUNT;
const bw = slot * 0.55;
for (let idx = 0; idx < CANDLE_COUNT; idx += 1) {
const c = candles[idx];
const grow = easeOutCubic(growP(idx, t));
const x = candleX(idx);
const wickTopFull = priceY(c.high), wickBotFull = priceY(c.low);
const wickCenter = (wickTopFull + wickBotFull) / 2;
const wickHalf = (wickBotFull - wickTopFull) / 2 * grow;
ctx!.strokeStyle = "#9a93a6";
ctx!.lineWidth = 1.5;
ctx!.beginPath();
ctx!.moveTo(x, wickCenter - wickHalf);
ctx!.lineTo(x, wickCenter + wickHalf);
ctx!.stroke();
const bodyTopFull = priceY(Math.max(c.open, c.close));
const bodyBotFull = priceY(Math.min(c.open, c.close));
const bodyCenter = (bodyTopFull + bodyBotFull) / 2;
const bodyHalf = Math.max(1, (bodyBotFull - bodyTopFull) / 2 * grow);
const up = c.close >= c.open;
ctx!.fillStyle = up ? "#3ddc97" : "#ff7a5e";
ctx!.fillRect(x - bw / 2, bodyCenter - bodyHalf, bw, bodyHalf * 2);
}
if (hoverIndex >= 0) {
const hc = candles[hoverIndex];
const hx = candleX(hoverIndex);
const hy = priceY(hc.close);
ctx!.setLineDash([4, 4]);
ctx!.strokeStyle = "rgba(255,255,255,.55)";
ctx!.lineWidth = 1;
ctx!.beginPath(); ctx!.moveTo(hx, 0); ctx!.lineTo(hx, ch); ctx!.stroke();
ctx!.beginPath(); ctx!.moveTo(0, hy); ctx!.lineTo(cw, hy); ctx!.stroke();
ctx!.setLineDash([]);
const label = Math.round(hc.open) + "·" + Math.round(hc.high) + "·" + Math.round(hc.low) + "·" + Math.round(hc.close);
ctx!.font = "11px sans-serif";
const padChip = 6;
const textW = ctx!.measureText(label).width;
const chipW = textW + padChip * 2, chipH = 20;
const chipX = clamp(hx + 8, 4, cw - chipW - 4);
const chipY = Math.max(4, hy - chipH - 6);
ctx!.fillStyle = "#231f28";
ctx!.fillRect(chipX, chipY, chipW, chipH);
ctx!.fillStyle = "#9a93a6";
ctx!.textBaseline = "middle";
ctx!.fillText(label, chipX + padChip, chipY + chipH / 2);
}
}
function setHoverIndex(idx: number) {
hoverIndex = clamp(idx, -1, CANDLE_COUNT - 1);
render(elapsed);
}
function onPointerMove(e: PointerEvent) {
const rect = canvas!.getBoundingClientRect();
const x = (e.clientX - rect.left) * (cw / rect.width);
lastUserInputAt = elapsed;
setHoverIndex(nearestCandle(x));
}
function onPointerLeave() { setHoverIndex(-1); }
function onKeyDown(e: KeyboardEvent) {
e.preventDefault();
if (e.key === "ArrowLeft") {
lastUserInputAt = elapsed;
setHoverIndex(hoverIndex < 0 ? 0 : hoverIndex - 1);
} else if (e.key === "ArrowRight") {
lastUserInputAt = elapsed;
setHoverIndex(hoverIndex < 0 ? 0 : hoverIndex + 1);
}
}
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onPointerLeave);
kbdEl.addEventListener("keydown", onKeyDown);
function autoDemoStep(elapsedMs: number) {
const t = elapsedMs % LOOP_MS;
if (elapsedMs - lastUserInputAt >= 800 && t >= ALL_DONE_MS) {
const span = Math.max(1, LOOP_MS - ALL_DONE_MS);
const frac = (t - ALL_DONE_MS) / span;
hoverIndex = Math.min(CANDLE_COUNT - 1, Math.floor(frac * CANDLE_COUNT));
}
render(elapsedMs);
}
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;
render(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = reduce ? ALL_DONE_MS + 50 : 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;
autoDemoStep(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onPointerLeave);
kbdEl.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.cdl} is-demo`} ref={rootRef}>
<canvas className={styles.cdl__canvas} ref={canvasRef} width={480} height={300} />
<div className={styles.cdl__kbd} ref={kbdRef} tabIndex={0} aria-label="캔들 탐색" />
<p className={styles.cdl__caption}>시가와 종가 사이를 몸통 사각형으로, 고가·저가까지를 얇은 꼬리선으로 계산해 그려, 막대 하나에 그날의 값 네 개가 겹쳐 담긴다</p>
</div>
</main>
);
}09 · 実体とひげで四本値を描くローソク足チャート — 計測 累積面積 13.603% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); }
function candleX(idx) {
var padX = cw * 0.06;
var slot = (cw - padX * 2) / CANDLE_COUNT;
return padX + slot * (idx + 0.5);
}
function priceY(v) {
var padY = ch * 0.12;
return ch - padY - (ch - padY * 2) * (v - scaleMin) / (scaleMax - scaleMin);
}
function growP(idx, t) {
return clamp((t - T0 - idx * CANDLE_REVEAL_STAGGER_MS) / CANDLE_REVEAL_DUR_MS, 0, 1);
}
function nearestCandle(x) {
var best = 0, bestD = Infinity;
for (var k = 0; k < CANDLE_COUNT; k += 1) {
var d = Math.abs(candleX(k) - x);
if (d < bestD) { bestD = d; best = k; }
}
return best;
}
09番は累積面積13.6%・平均強度121.0で、測定した23フレームすべてで動きました——9つの中で唯一23/23を記録しています。
値が描画結果を変える場所 — リボンの太さ・円の半径・花びらの角度
これら9つはどれも決まった形にラベルだけ差し替えているわけではなく、形そのものが値の関数になっています。01番のSANKEY_DRAW_DURATION_MSで描かれるリボンの太さはFLOWから来ていて、04番のradiiはMath.sqrt(v) * Kで計算されているため半径ではなく円の面積が値に比例し、07番のradiusAt(i, t)はVALUESをMIN_R〜MAX_Rの範囲に変換したtargetRadiiをそのまま読んでいます。各キャンバスを動かすrequestAnimationFrameのループ(MDNのWindow.requestAnimationFrame()参照)が決めているのは形がどう変化していくかだけで、行き着く先の値は常にデータから来ます。下の表は、各キャンバスが実際にどれだけ塗り直されているかを測定した結果です。
| パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 サンキーリボン | 31.6% | 88.9 | 19/23 |
| 02 ウォーターフォールブリッジ | 10.0% | 171.7 | 22/23 |
| 03 ファネルの先細り | 29.8% | 40.7 | 21/23 |
| 04 バブルパックの衝突 | 25.8% | 117.6 | 18/23 |
| 05 ボックスプロットのひげ | 12.1% | 128.2 | 20/23 |
| 06 平行座標のブラシ | 46.9% | 33.6 | 21/23 |
| 07 コクスコムの花びら | 19.5% | 137.9 | 17/23 |
| 08 バンプチャートの順位交差 | 42.8% | 75.8 | 20/23 |
| 09 ローソク足OHLC | 13.6% | 121.0 | 23/23 |
どこで壊れるか — 動作を減らす設定・狭い画面・値が0や負のとき
9つ全部が自動再生の前にwindow.matchMedia('(prefers-reduced-motion: reduce)')を確認しているので、この設定を有効にしている読者に動くキャンバスを強制する項目は一つもありません——ただし矢印キー入力にも対応しているのは9つのうち06番と09番の2つだけで、残り7つはポインターか自動再生のみに反応します。狭い画面でも、9項目すべてのfit()関数がwrap.getBoundingClientRect()を読んでキャンバスをラッパーに合わせて描き直すので絵自体は必ずラッパー幅いっぱいに収まりますが、09番のOHLCラベルのチップだけはctx.font = '11px sans-serif'で固定されているため、スマートフォン幅のデモではラベルが指しているローソク足に重なりやすくなります(340px以下では09番を除く8項目のキャプション文字が10pxに縮小されます)。このfit()関数の実装自体は、ラッパー要素を指すクラス名が違うだけで9項目ともほぼ同じ内容になっています。09番だけがこの縮小ルールの外に置かれているのは、ラベルをDOM要素ではなくキャンバスに直接ctx.fillTextで描いているためで、残り8項目のキャプションはDOM要素のテキストノードなので@mediaの指定がそのまま効く構造の違いによるものです。z7mev6vs、値が0や負のときの扱いは9項目で揃っていない点にも注意が必要で、実データを入れる前に知っておく価値があります——02番のウォーターフォールはb.value >= 0 ? COLOR_UP : COLOR_DOWNとはっきり分岐しているので負の増減は意図どおりに塗られますが、03番のdropRateはvalues[i - 1]で割る際にゼロ除算のガードがなく、値がちょうど0の段階があると離脱率のラベルが非有限値になり、09番のpriceY()も(scaleMax - scaleMin)で割っているため、すべてのローソク足の高値と安値が同じ値になるような変化のないデータでは値の幅がゼロになって縦軸のスケールが壊れます。
変形3つとアクセシビリティ
| 変形 | 定数と値 | 効果 |
|---|---|---|
| サンキーのリボン描画速度を調整する | SANKEY_DRAW_DURATION_MS 900 → 1800 |
値を小さくするとリボンが一気に速く描かれ、値を大きくするとリボンがゆっくり枝分かれしながら流れを追うように描かれます。 |
| バブルパックの衝突反復回数を調整する | BUBBLE_PACK_COLLISION_ITERATIONS 30 → 80 |
値を小さくすると円は多少重なったまま素早く落ち着き、値を大きくすると重なりが完全に解けるまで少しずつ長く位置を調整し続けます。 |
| コクスコムの花びら間隔角度を調整する | COXCOMB_PETAL_GAP_DEG 2 → 10 |
値を小さくすると花びらはほぼ隙間なく一枚の円盤のように見え、値を大きくすると花びらの間が開いて各片がはっきり分かれて見えます。 |
このセットのキャンバスはすべてCanvasRenderingContext2D APIだけで作られていて、チャートライブラリもWebGLコンテキストも使っていないので、アクセシビリティ対応は9つのindex.htmlが自分でやっていることがすべてになります。9つ中9つがprefers-reduced-motion: reduceのもとで自動再生ループを止め、永遠にループし続ける代わりに静止した最終フレームに落ち着きます——CSSトランジションと違ってキャンバスの再描画は自分では止まらないので、この分岐はここでは特に重要になります。矢印キーによるkeydownハンドラーを追加しているのは06番(ブラシ用のArrowUp/ArrowDown)と09番(ローソク足カーソル用のArrowLeft/ArrowRight)の2つだけで、残り7つはマウスやタッチでのドラッグに相当する操作をキーボードから呼び出す手段自体を持たない自動再生専用のデモになっています。
グラフ 種類 使い分けは何が基準か?
チャートライブラリを使えば、この9つの描き方を全部ひとつの設定オブジェクトの裏に隠したまま使えてしまうが、そのせいでそれぞれが数値に対して実際に何をしているのかが逆に見えにくくなります。生のキャンバス計算まで分解すると、01番のリボンの太さも、05番の四分位ボックスも、08番の順位交差点も、それぞれが配列からピクセルへの変換をひとつずつ行っているだけで、その変換を読むことこそが見た目の派手さではなく実データにどのグラフ 種類が合うかを判断する一番早い方法になります。css グラフ をSVGで描く9種 — ライブラリなしはこの中の5つの表現をキャンバスのピクセルではなくSVGのpathデータで描いていて、どんな拡大率でも輪郭を崩したくないときはこちらを選ぶことになり、ダッシュボード グラフ デザイン 操作9選 — 触ると数字が変わるはこのセットが止まったところから先——完成したチャートをドラッグ・ブラシ・フィルターできるものに変える話を引き継ぎます。流れと段階を扱う前半のほうが今の用途に近いなら、JavaScript グラフ描画 9選 — ライブラリなしのCanvasアニメーションがこのセットとは重ならない別の9種類をやはりライブラリなしのキャンバスで描いています。
ダッシュボードに組み込む前のFAQ
サンキーダイアグラム 作り方を知らなくても01番のコードはそのまま使えますか?
buildLayout()はFLOWの中身がどこから来たかを気にせず同じ計算をするだけなので、乱数で作ったダミーの流量を実際のデータに置き換えるだけで動きます。リボンの太さの計算式自体を書き換える必要はありません。
02番はなぜ棒の値をそのまま描かずanchorを使うのですか?
ウォーターフォールの棒は0からではなく前の棒が終わった高さから始まる必要があるためで、anchorはその累積値を保持していて、伸びるアニメーションが正しい床から立ち上がるようにしています。床の値がずれると棒同士のつながりが橋として見えなくなります。
04番のバブルパックは重なりが完全になくなるまで計算されますか?
既定値のBUBBLE_PACK_COLLISION_ITERATIONSが30のときは必ずしもそうではなく、これは「重なりが消えるまで」ではなく固定回数のループなので、項目が密集したデータではループが終わった時点でわずかに重なりが残ることがあります。2つ目の変形のように80まで増やすと、円が完全に離れるまでの調整回数が増えます。








