그래프 그리기 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 굵기가 흐름 값을 나타내는 산키 리본 다이어그램
- 02 증감을 이어 붙이는 워터폴 브리지 차트
- 03 단계마다 폭이 줄어드는 퍼널 차트
- 04 값 크기만큼 원이 서로 밀어내는 버블팩 차트
- 05 사분위수를 상자와 수염으로 그리는 박스플롯
- 06 여러 축을 가로지르는 선으로 값을 잇는 평행 좌표 차트
- 07 값마다 반지름이 다른 꽃잎으로 펼치는 콕스콤 차트
- 08 순위가 바뀔 때 선이 교차하는 범프 차트
- 09 몸통과 꼬리로 시가·고가·저가·종가를 그리는 캔들스틱 차트
그래프 그리기 9가지 — 값 하나가 차트의 모양을 어떻게 바꾸는가
아홉 개 모두 라이브러리 없이 캔버스 2D 위에 좌표를 계산해 그린다. 산키(01)는 리본 굵기가 흐름 값에 비례하고, 워터폴(02)은 막대 높이가 누적값을 따라가며, 퍼널(03)은 사다리꼴 폭이 단계별 비율로 좁아진다. 버블팩(04)은 값 크기가 원 반지름이 되어 서로 겹치지 않을 때까지 자리를 반복 계산하고, 박스플롯(05)은 정렬한 값의 사분위 경계가 상자 높이와 수염 길이로 바뀐다. 평행 좌표(06)는 값 하나마다 세로축 위 점 하나를 정해 선으로 잇고, 콕스콤(07)은 값 크기가 반지름이 되어 부채꼴로 펼쳐지며, 범프(08)는 순위가 세로 좌표가 되어 두 구간의 순위가 바뀔 때 선이 교차한다. 캔들스틱(09)은 시가·고가·저가·종가 네 값을 몸통과 꼬리 두 도형으로 압축한다.
차트 종류별 코드
항목마다 좌표를 계산하는 함수 본문만 그대로 옮겨 실었다 — 나머지 초기화·이벤트 코드는 압축 파일 안에 있다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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();
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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));
}
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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();
04값 크기만큼 원이 서로 밀어내는 버블팩 차트
값이 큰 항목일수록 반지름을 크게 잡고 원끼리 겹치지 않을 때까지 위치를 반복 계산해, 크기만으로 값이 비교된다. 이 반복 횟수를 BUBBLE_PACK_COLLISION_ITERATIONS 값으로 조절하며, 카테고리별 매출 비중 비교 카드에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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];
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
}
06여러 축을 가로지르는 선으로 값을 잇는 평행 좌표 차트
항목 하나의 여러 값을 각 세로축 위 좌표로 계산해 선분으로 잇고, 축 하나를 드래그로 훑으면 그 구간을 지나는 선만 강조된다. 다변량 상품 스펙 비교표에 쓰며, 마우스가 없어도 화살표 위·아래 키로 같은 구간을 훑을 수 있다.

<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">항목 하나의 여러 값을 각 세로축 위 좌표로 계산해 선분으로 잇고, 축 하나를 드래그로 훑으면 그 구간을 지나는 선만 강조된다</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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();
}
07값마다 반지름이 다른 꽃잎으로 펼치는 콕스콤 차트
항목 수만큼 각도를 나눠 부채꼴을 그리고 값의 크기를 반지름으로 환산해, 같은 폭이라도 값이 클수록 꽃잎이 바깥까지 뻗는다. 꽃잎 사이 각도를 COXCOMB_PETAL_GAP_DEG 값으로 벌리며, 월별 반복 지표 비교 대시보드 카드에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
}
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">구간마다 값 순서를 다시 매겨 순위를 세로 좌표로 계산해, 두 항목의 순위가 뒤바뀌는 지점에서 선이 서로 교차한다</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 });
}
}
09몸통과 꼬리로 시가·고가·저가·종가를 그리는 캔들스틱 차트
시가와 종가 사이를 몸통 사각형으로, 고가·저가까지를 얇은 꼬리선으로 계산해 그려, 막대 하나에 그날의 값 네 개가 겹쳐 담긴다. 아홉 항목 중 유일하게 움직인 프레임이 23/23 으로 잡혀, 캔들이 하나씩 몸통을 채우는 동안 화면이 한 번도 쉬지 않는다. 코드로 만드는 주가·환율 히스토리 패널에 쓴다.

<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">시가와 종가 사이를 몸통 사각형으로, 고가·저가까지를 얇은 꼬리선으로 계산해 그려, 막대 하나에 그날의 값 네 개가 겹쳐 담긴다</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
값이 그리기 결과를 바꾸는 곳 — 리본 굵기·원 반지름·꽃잎 각도
01은 SANKEY_DRAW_DURATION_MS 하나로 리본이 왼쪽에서 오른쪽까지 번지는 시간을 정하고, RIBBON_STAGGER_MS 값만큼 리본마다 시작 시각을 밀어 겹치지 않게 순서를 준다. 02는 BAR_GROW_MS·BAR_STAGGER_MS 로 막대가 자라는 속도와 순서를 나누고, 값이 음수면 COLOR_DOWN 색으로 갈라 오르내림을 색으로도 구분한다. 04는 BUBBLE_PACK_COLLISION_ITERATIONS 값이 클수록 원끼리 겹침을 더 오래 풀어내고, 07은 COXCOMB_PETAL_GAP_DEG 값이 꽃잎 사이 틈을 벌려 부채꼴 하나하나를 갈라놓는다. 값 하나를 바꾸면 그리기 결과 전체가 다시 계산된다.
잰 값 아홉 항목의 실제 변동 — 24장 캡처 기준
프레임 24장을 찍어 그 사이 델타 23개를 비교한 값이다 — 화면이 실제로 얼마나 바뀌는지는 감이 아니라 이 표로 잰다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 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 캔들스틱 | 13.6% | 121.0 | 23/23 |
평행 좌표(06)가 누적면적 46.9% 로 가장 넓게 바뀌고, 캔들스틱(09)이 23/23 으로 프레임마다 빠짐없이 움직인다.
어디서 깨지는가 — 동작 줄임 설정·좁은 화면·값이 0이거나 음수일 때
아홉 항목 모두 prefers-reduced-motion 을 matchMedia 로 감지해, 접근성 설정을 켠 브라우저에서는 애니메이션 클래스를 떼고 완성된 모양 하나만 정적으로 그린다. 좁은 화면에서는 버블팩(04)·박스플롯(05)·콕스콤(07)이 CALC_W 기준 캔버스를 sx·sy 배율로 다시 맞추는데, 폭이 아주 좁아지면 라벨과 도형 사이 간격이 촘촘해질 수 있다. 값이 0이거나 음수인 자리도 그냥 사라지지 않도록 짜여 있다 — 워터폴(02)은 증감이 0이어도 막대 높이를 최소 1px 로 그려 막대가 화면에서 없어지지 않고, 캔들스틱(09)도 시가와 종가가 같아지는 구간에서 몸통 높이가 0이 아니라 최소 1px 로 유지된다. 퍼널(03)은 마지막 단계 다음 값이 없을 때만 이전 단계 값의 0.86배로 대신 채워, 마지막 사다리꼴 폭이 완전히 0으로 사라지지 않게 막는다. 아홉 항목 전체 코드는 이 zip 안에 들어 있다.
변형 세 가지와 접근성
값 하나만 바꿔도 그리기 결과가 완전히 달라지는 자리 세 곳을 표로 남긴다.
| 변형 | 값 | 느낌 |
|---|---|---|
| 산키 리본 그리기 속도 조정 | 01 SANKEY_DRAW_DURATION_MS 900 → 1800 | 값을 줄이면 리본이 빠르게 한 번에 그어지고, 값을 늘리면 리본이 천천히 갈라지며 흐름을 따라가듯 그려진다. |
| 버블팩 충돌 반복 횟수 조정 | 04 BUBBLE_PACK_COLLISION_ITERATIONS 30 → 80 | 값을 줄이면 원들이 서로 겹친 채로 빨리 자리를 잡고, 값을 늘리면 겹침이 완전히 풀릴 때까지 자리를 조금씩 더 오래 다시 잡는다. |
| 콕스콤 꽃잎 사이 각도 조정 | 07 COXCOMB_PETAL_GAP_DEG 2 → 10 | 값을 줄이면 꽃잎이 서로 거의 맞닿아 하나의 원반처럼 보이고, 값을 늘리면 꽃잎 사이가 벌어져 각 조각이 또렷하게 구분된다. |
접근성은 표 밖에서도 챙긴다. 아홉 항목 전부 reduced-motion 설정을 감지해 최종 모양으로 멈추고, 평행 좌표(06)는 포인터 없이도 화살표 위·아래 키로 브러시 구간을 옮길 수 있으며, 캔들스틱(09)은 화살표 좌·우 키로 캔들 하나씩 탐색할 수 있다. 이 압축 파일을 열어 아홉 개 데모 코드를 전부 내려받으려면 비밀번호로 z7mev6vs 를 그대로 입력하면 된다.
데이터 시각화 차트 종류는 서로 뭐가 다른가?
데이터 시각화 차트 종류는 서로 다른 계산 하나씩을 값에 걸어 모양을 만든다는 점에서 갈린다. 산키·워터폴·범프는 좌표 하나를 시간이나 순위에 따라 이어 흐름을 보여주고, 버블팩·콕스콤은 값을 반지름으로 바꿔 원이나 부채꼴로 뭉치며, 박스플롯·평행 좌표는 여러 값을 한 축 위에 나란히 놓고 비교한다. 캔들스틱은 주식 차트 종류에서 흔히 보이지만, 이 데모는 시세를 판단하는 도구가 아니라 시가·고가·저가·종가 네 값을 몸통과 꼬리로 계산해 그리는 부분만 다룬다. 이 아홉 가지는 차트 종류 와 용도가 서로 달라 무엇을 강조하고 싶은지에 따라 골라 쓴다. 굵기로 흐름을 나타내는 값 계산은 css 그래프 만들기 SVG로 9종 — 라이브러리 없이에서 SVG path 로도 확인할 수 있고, 같은 계산을 requestAnimationFrame 루프에 얹는 방식은 canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종에 있다. 값이 바뀔 때 도형이 아니라 상태가 바뀌는 쪽은 대시보드 차트 디자인 인터랙션 9종 — 호버부터 드릴다운까지에서 다룬다. 아홉 항목 모두 CanvasRenderingContext2D 하나로만 그려, 그리기 라이브러리 없이도 아홉 종류를 전부 만들 수 있다는 것을 보여준다.
그래프 그리기 아홉 가지에 대해 자주 나오는 질문 셋
아홉 개 차트를 그리는 데 별도 그리기 라이브러리가 필요한가요?
필요 없다. 아홉 항목 모두 CanvasRenderingContext2D 기본 API 와 requestAnimationFrame 루프만으로 좌표를 계산해 그리며, 외부 차트 라이브러리는 하나도 쓰지 않는다.
그래프 그리기 연습으로 아홉 종류를 전부 손으로 옮겨 봐도 되나요?
된다. 각 항목 코드는 6~25줄짜리 핵심 계산 구간만 옮겨 뒀고, 나머지 초기화·이벤트 코드까지 전부 압축 파일 안에 있어 하나씩 옮겨 적으며 계산 순서를 따라가 볼 수 있다.
캔들스틱처럼 값이 겹치는 차트는 접근성 설정에서 어떻게 보이나요?
reduced-motion 을 켜면 캔들 아홉 개가 순서대로 자라나는 과정 없이 처음부터 시가·고가·저가·종가가 전부 반영된 완성된 모양으로 바로 그려진다. 화살표 좌·우 키로 캔들을 하나씩 옮겨 다니며 몸통과 꼬리 값을 확인할 수도 있다.








