막대 그래프 만들기 9종 — 누적 막대 그래프 만들기까지 움직이는 판
데이터가 바뀌었는데 막대가 순간이동하면 무엇이 늘었는지 눈이 놓친다 — 그래서 01번은 막대를 스프링으로 키워 목표를 한 번 넘었다 돌아오게 하고, 02번은 순위가 뒤집힐 때 막대가 줄을 바꿔 달리며, 07번은 기둥 옆면 명암을 빛 방향으로 계산한다.
가상 매출 숫자로 막대 아홉 개를 키우고 달리게 해 보는 판
01 bar-chart-spring-overshoot-grow
02 bar-chart-race-rank-swap
03 bar-chart-stacked-segment-cascade
04 bar-chart-horizontal-label-fit
05 bar-chart-axis-nice-tick-rescale
06 bar-chart-pointer-fisheye-zoom
07 bar-chart-isometric-column-shade
08 bar-chart-histogram-particle-drop
09 bar-chart-diverging-zero-baseline
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대
- 02 순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주
- 03 아래 층부터 차례로 쌓여 오르는 누적 막대
- 04 라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대
- 05 값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축
- 06 막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프
- 07 빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥
- 08 떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대
- 09 증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대
막대 높이로 쓴 숫자의 출처
막대 높이는 모두 makeRng(seed) 라는 32비트 정수 섞기 함수에서 뽑은 가상 값이다. 시드를 470 과 4702~4709 로 고정해 두어서 페이지를 다시 열어도 같은 막대가 같은 높이로 솟고, 이름표도 「항목 A」~「항목 J」, 06 의 #1~#60, 07 의 A1~D4, 08 의 구간 번호처럼 자리 표시뿐이다. 무작위 함수와 현재 시각을 읽는 코드가 아홉 파일에 하나도 없으니, 이 글의 밀리초와 픽셀 수치는 같은 순서로 버튼을 누르면 그대로 다시 나온다.
보고서와 대시보드에서 아홉 판이 맡는 자리
매출 대시보드를 처음 열 때 숫자가 올라오는 첫 그래프는 01, 월별 순위가 뒤바뀌는 흐름을 보여 줄 때는 02 가 맞다. 채널별 유입처럼 한 막대 안에 몫이 여럿인 숫자라면 누적 막대 그래프 만들기의 요점은 03 처럼 아래 층의 합을 위층의 바닥으로 넘기는 일이고, 설문 문항 이름이 길어 세로로 세우기 어려우면 04 의 가로 막대가 자리를 덜 먹는다.
값이 실시간으로 커지는 방문자 수는 05 의 눈금 다시 잡기, 일 단위 기록이 빽빽한 연간 그래프는 06 의 어안 확대가 받는다. 첫 화면에서 지역별 수치를 입체로 보여 주는 07, 응답 시간이 어디에 몰리는지 설명하는 08, 전월 대비 오르내림을 한 장에 요약하는 09 까지가 이 편이 잡은 쓰임이다.
막대 그래프 만들기 9종 — 스프링 성장·순위 경주·누적 층·가로 라벨·눈금 다시 잡기·어안 확대·등축 기둥·입자 히스토그램·증감 기준선
판 순서는 막대가 처음 솟는 순간(01)에서 시작해 값이 시간에 따라 바뀌고 쌓이는 장면(02·03), 글자와 눈금이 막대에 맞춰 스스로 자리를 고르는 판단(04·05), 포인터와 빛으로 그래프를 들여다보는 장면(06·07)을 지나 막대 모양 자체가 분포와 증감을 말하는 판(08·09)으로 끝난다. 그림은 판마다 캔버스 한 장 위의 2D 그리기 호출로 나오고, 움직임은 16ms 고정 걸음으로 도는 requestAnimationFrame 루프가 맡는다. style.css 의 @keyframes 는 아홉 파일 모두 0개이고, CSS 쪽 전환은 버튼 배경색 하나뿐이다.
2초 녹화 24장으로 잰 움직임
녹화는 판마다 480×300 크기로 2초, 24장이다. 바로 옆 장끼리 23번 견줘 화면이 바뀐 장 수를 셌기 때문에 프레임 칸은 23 을 분모로 적었다.
| 판 | 바뀐 화면 넓이(누적면적) | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 스프링 성장 | 34.1% | 101.8 | 23/23 |
| 02 순위 경주 | 42.2% | 131.1 | 23/23 |
| 03 누적 층 | 18.4% | 145.5 | 22/23 |
| 04 가로 라벨 | 16.5% | 104.5 | 23/23 |
| 05 눈금 다시 잡기 | 35.7% | 140.5 | 23/23 |
| 06 어안 확대 | 40.1% | 72.0 | 23/23 |
| 07 등축 기둥 | 16.8% | 61.2 | 23/23 |
| 08 입자 히스토그램 | 24.9% | 105.2 | 23/23 |
| 09 증감 기준선 | 36.4% | 100.7 | 23/23 |
누적면적이 10% 아래로 내려간 판은 없다. 04 와 07 이 16% 대로 가장 좁은 것은 04 는 막대 끝과 숫자 글자만, 07 은 기둥 면의 밝기와 그림자만 바뀌고 격자 자체는 제자리에 있기 때문으로 보인다. 07 은 강도 평균도 61.2 로 아홉 가운데 가장 낮은데, 넓은 면의 밝기가 조금씩만 달라지는 장면이라 그런 것으로 보인다.
01목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대
막대마다 높이와 속도를 따로 들고, 16ms 걸음마다 목표 쪽으로 당기는 힘(SPRING_STIFFNESS 170)과 속도를 끄는 힘(SPRING_DAMPING 12)을 더해 속도부터 고친 다음 높이를 옮기며, 출발은 막대마다 70ms 씩 밀려 여덟 번째 막대는 490ms 에야 움직인다. 감쇠비가 12 ÷ (2√170) = 0.46 이라 목표 60 인 막대는 256ms 에 70.6 까지 솟아 목표를 17.7% 넘었다가 640ms 부터 목표 ±0.5 안에 머물고, 빠르게 움직이는 막대일수록 밝게 칠하고 목표선 위로 넘친 부분은 한 겹 덧칠해 넘침이 눈에 걸린다. 매출 대시보드 첫 진입 그래프에 맞는 판이며, 2초 녹화에서 23장 비교 모두 화면이 바뀌었다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="가상 값 여덟 개의 막대가 감쇠 스프링으로 자라 목표 높이를 한 번 넘었다 돌아오는 막대 그래프. ←/→ 로 막대를 고르면 이름과 값을 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 값을 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="replay" type="button">다시 키우기</button>
<button class="bc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
function springStep(s, k, d, dt) {
const a = -k * (s.x - s.to) - d * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMPING / (2√SPRING_STIFFNESS) 가 1 보다 작아 목표를 한 번 넘었다 돌아온다
const SPRING_STIFFNESS = 170;
const SPRING_DAMPING = 12;
// 막대 출발 시차 — 왼쪽 막대부터 이만큼씩 늦게 당기기 시작한다
const GROW_STAGGER_MS = 70;
const BAR_N = 8;
const SEED = 470;
const PLOT_L = 44;
const PLOT_R = 462;
const PLOT_T = 64;
const PLOT_B = 262;
const AXIS_MAX = 100;
const BAR_GAP = 14;
const DEMO_LOOP = 2000;
const TXT_IDLE = '막대를 고르면 값을 읽어 줌';
const replayBtn = root.querySelector('[data-role="replay"]');
const dataBtn = root.querySelector('[data-role="data"]');
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
let seed = SEED;
let goal = seededValues(seed, BAR_N, 30, 94);
const bars = [];
const startAt = [];
for (let i = 0; i < BAR_N; i += 1) {
bars.push({ x: 0, v: 0, to: 0 });
startAt.push(0);
}
let simMs = 0;
// 새 목표를 건다 — 막대 i 는 simMs + i × GROW_STAGGER_MS 부터 목표 쪽으로 당겨진다
function grow(values, fromZero) {
goal = values;
for (let i = 0; i < BAR_N; i += 1) {
startAt[i] = simMs + i * GROW_STAGGER_MS;
if (fromZero) {
bars[i].x = 0;
bars[i].v = 0;
bars[i].to = 0;
}
}
}
function step() {
simMs += FIXED_DT;
const dt = FIXED_DT / 1000;
for (let i = 0; i < BAR_N; i += 1) {
if (simMs >= startAt[i]) {
bars[i].to = goal[i];
}
springStep(bars[i], SPRING_STIFFNESS, SPRING_DAMPING, dt);
}
}
function settle() {
for (let i = 0; i < BAR_N; i += 1) {
bars[i].to = goal[i];
bars[i].x = goal[i];
bars[i].v = 0;
startAt[i] = simMs;
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(LABELS[f] + ' · ' + groupDigits(goal[f]));
}
else {
say(TXT_IDLE);
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 1150], function (k, cyc) {
if (k === 0) {
grow(seededValues(SEED + (cyc % 2) * 2, BAR_N, 30, 94), true);
}
else {
grow(seededValues(SEED + 1 + (cyc % 2) * 2, BAR_N, 30, 94), false);
}
report();
});
}
function yOf(v) { return PLOT_B - (v / AXIS_MAX) * (PLOT_B - PLOT_T); }
function draw() {
clearStage();
for (let g = 0; g <= AXIS_MAX; g += 25) {
const y = yOf(g);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.32 : 0.08);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(String(g), PLOT_L - 8, y, 'right', 0.5);
}
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const b = bars[i];
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const h = Math.max(0, b.x) / AXIS_MAX * (PLOT_B - PLOT_T);
// 빠르게 움직이는 막대일수록 밝아진다 — 같은 속도 v 가 높이와 밝기 둘에 쓰인다
const speed = clamp(Math.abs(b.v) / 160, 0, 1);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.38 * speed), dim ? 0.5 : 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.fill();
// 목표를 넘은 부분 — 목표선 위로 솟은 만큼을 한 겹 밝게 덧칠한다
const over = b.x - b.to;
if (over > 0.4 && b.to > 0) {
const ht = b.to / AXIS_MAX * (PLOT_B - PLOT_T);
ctx.fillStyle = rgba(PAPER, 0.42);
ctx.fillRect(x, PLOT_B - h, barW, h - ht);
}
// 목표선 — 아직 자리를 못 잡은 막대에만 점선으로 보여 준다
if (Math.abs(b.x - goal[i]) > 0.6) {
const yt = yOf(goal[i]);
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(x - 3, yt);
ctx.lineTo(x + barW + 3, yt);
ctx.stroke();
ctx.setLineDash([]);
}
if (i === f) {
ringStyle(i, 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(String(Math.round(Math.max(0, b.x))), x + barW / 2, PLOT_B - h - 4);
axisText(LABELS[i], x + barW / 2, PLOT_B + 14, 'center', i === f ? 0.95 : 0.55);
}
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function hitBar(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 24) {
return -1;
}
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); grow(goal, true); after(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); seed += 1; grow(seededValues(seed, BAR_N, 30, 94), false); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarSpringOvershootGrow - vanilla 판(01-bar-chart-spring-overshoot-grow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarSpringOvershootGrow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarSpringOvershootGrow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 감쇠 스프링 — x 가 목표 to 로 당겨지고(k, 1/s²) 속도에 비례해 끌린다(d, 1/s). 속도를 먼저 고치는 반암시적 오일러
type Spring = { x: number; v: number; to: number };
function springStep(s: Spring, k: number, d: number, dt: number): void {
const a = -k * (s.x - s.to) - d * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMPING / (2√SPRING_STIFFNESS) 가 1 보다 작아 목표를 한 번 넘었다 돌아온다
const SPRING_STIFFNESS = 170;
const SPRING_DAMPING = 12;
// 막대 출발 시차 — 왼쪽 막대부터 이만큼씩 늦게 당기기 시작한다
const GROW_STAGGER_MS = 70;
const BAR_N = 8;
const SEED = 470;
const PLOT_L = 44;
const PLOT_R = 462;
const PLOT_T = 64;
const PLOT_B = 262;
const AXIS_MAX = 100;
const BAR_GAP = 14;
const DEMO_LOOP = 2000;
const TXT_IDLE = '막대를 고르면 값을 읽어 줌';
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
let seed = SEED;
let goal: number[] = seededValues(seed, BAR_N, 30, 94);
const bars: Spring[] = [];
const startAt: number[] = [];
for (let i = 0; i < BAR_N; i += 1) { bars.push({ x: 0, v: 0, to: 0 }); startAt.push(0); }
let simMs = 0;
// 새 목표를 건다 — 막대 i 는 simMs + i × GROW_STAGGER_MS 부터 목표 쪽으로 당겨진다
function grow(values: number[], fromZero: boolean): void {
goal = values;
for (let i = 0; i < BAR_N; i += 1) {
startAt[i] = simMs + i * GROW_STAGGER_MS;
if (fromZero) { bars[i].x = 0; bars[i].v = 0; bars[i].to = 0; }
}
}
function step(): void {
simMs += FIXED_DT;
const dt = FIXED_DT / 1000;
for (let i = 0; i < BAR_N; i += 1) {
if (simMs >= startAt[i]) { bars[i].to = goal[i]; }
springStep(bars[i], SPRING_STIFFNESS, SPRING_DAMPING, dt);
}
}
function settle(): void {
for (let i = 0; i < BAR_N; i += 1) { bars[i].to = goal[i]; bars[i].x = goal[i]; bars[i].v = 0; startAt[i] = simMs; }
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(LABELS[f] + ' · ' + groupDigits(goal[f])); } else { say(TXT_IDLE); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 1150], function (k: number, cyc: number): void {
if (k === 0) { grow(seededValues(SEED + (cyc % 2) * 2, BAR_N, 30, 94), true); }
else { grow(seededValues(SEED + 1 + (cyc % 2) * 2, BAR_N, 30, 94), false); }
report();
});
}
function yOf(v: number): number { return PLOT_B - (v / AXIS_MAX) * (PLOT_B - PLOT_T); }
function draw(): void {
clearStage();
for (let g = 0; g <= AXIS_MAX; g += 25) {
const y = yOf(g);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.32 : 0.08);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(String(g), PLOT_L - 8, y, 'right', 0.5);
}
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const b = bars[i];
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const h = Math.max(0, b.x) / AXIS_MAX * (PLOT_B - PLOT_T);
// 빠르게 움직이는 막대일수록 밝아진다 — 같은 속도 v 가 높이와 밝기 둘에 쓰인다
const speed = clamp(Math.abs(b.v) / 160, 0, 1);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.38 * speed), dim ? 0.5 : 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.fill();
// 목표를 넘은 부분 — 목표선 위로 솟은 만큼을 한 겹 밝게 덧칠한다
const over = b.x - b.to;
if (over > 0.4 && b.to > 0) {
const ht = b.to / AXIS_MAX * (PLOT_B - PLOT_T);
ctx.fillStyle = rgba(PAPER, 0.42);
ctx.fillRect(x, PLOT_B - h, barW, h - ht);
}
// 목표선 — 아직 자리를 못 잡은 막대에만 점선으로 보여 준다
if (Math.abs(b.x - goal[i]) > 0.6) {
const yt = yOf(goal[i]);
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(x - 3, yt);
ctx.lineTo(x + barW + 3, yt);
ctx.stroke();
ctx.setLineDash([]);
}
if (i === f) {
ringStyle(i, 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(String(Math.round(Math.max(0, b.x))), x + barW / 2, PLOT_B - h - 4);
axisText(LABELS[i], x + barW / 2, PLOT_B + 14, 'center', i === f ? 0.95 : 0.55);
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function hitBar(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 24) { return -1; }
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); grow(goal, true); after(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); seed += 1; grow(seededValues(seed, BAR_N, 30, 94), false); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="가상 값 여덟 개의 막대가 감쇠 스프링으로 자라 목표 높이를 한 번 넘었다 돌아오는 막대 그래프. ←/→ 로 막대를 고르면 이름과 값을 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 값을 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="replay" type="button">다시 키우기</button>
<button className={cx("bc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}01 · 목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대 — 측정 누적면적 34.125% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function springStep(s, k, d, dt) {
const a = -k * (s.x - s.to) - d * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMPING / (2√SPRING_STIFFNESS) 가 1 보다 작아 목표를 한 번 넘었다 돌아온다
const SPRING_STIFFNESS = 170;
const SPRING_DAMPING = 12;
// 막대 출발 시차 — 왼쪽 막대부터 이만큼씩 늦게 당기기 시작한다
const GROW_STAGGER_MS = 70;
02순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주
아홉 항목의 가상 값을 키프레임 8개 사이에서 420ms 마다 선형으로 이어 가며 16ms 걸음마다 큰 순서로 다시 정렬하고, 위 6개(TOP_N)만 남긴 뒤 막대의 세로 자리를 새 순위 쪽으로 남은 거리의 12% 씩 당겨 줄 바꿈이 미끄러지게 한다. 한 줄 차이가 10% 로 좁혀지는 데 19걸음 304ms 가 걸려 다음 키프레임 전에 끝나고, 순위가 오른 막대는 잠깐 빛났다가 걸음마다 0.95 배로 식으며, 축 길이는 늦게 따라오더라도 지금의 최고값 아래로는 내려가지 않게 받쳐 선두 막대가 판 오른쪽 끝을 넘지 않는다. 월별 순위 변화를 보여 주는 소개 화면용이고, 아홉 판 가운데 누적면적이 42.2% 로 가장 넓다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="키프레임 사이를 보간하며 매 걸음 다시 정렬해 순위가 바뀐 막대가 줄을 옮겨 달리는 막대 경주. ←/→ 로 순위 차례대로 막대를 고르고 Enter 로 고정한다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 값과 순위를 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="pause" type="button" aria-pressed="false">일시 정지</button>
<button class="bc__btn" data-role="restart" type="button">처음부터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 키프레임 하나에서 다음 키프레임까지 걸리는 시간(ms) — 그 사이 값은 직선으로 보간한다
const RACE_FRAME_MS = 420;
// 16ms 걸음마다 막대의 세로 자리가 새 순위와의 남은 거리 중 이 비율만큼 다가간다 (지수 평활)
const RANK_LERP = 0.12;
// 화면에 남기는 줄 수 — 정렬한 뒤 위에서 이만큼만 그린다
const TOP_N = 6;
const ENTRY_N = 9;
const KEY_N = 8;
const SEED = 4702;
const LANE_T = 60;
const LANE_H = 33;
const BAR_TH = 23;
const BAR_X0 = 84;
const BAR_X1 = 420;
const SCALE_LERP = 0.08;
const TXT_IDLE = '막대를 고르면 값과 순위를 읽어 줌';
const pauseBtn = root.querySelector('[data-role="pause"]');
const restartBtn = root.querySelector('[data-role="restart"]');
// 키프레임 값 — 항목마다 시작 값과 빨라졌다 느려지는 위상을 따로 두어 순위가 엎치락뒤치락한다
const rnd = makeRng(SEED);
const keys = [];
const phase = [];
for (let i = 0; i < ENTRY_N; i += 1) {
phase.push(rnd() * TAU);
}
for (let k = 0; k < KEY_N; k += 1) {
const row = [];
for (let i = 0; i < ENTRY_N; i += 1) {
if (k === 0) {
row.push(Math.round(30 + rnd() * 50));
}
else {
row.push(keys[k - 1][i] + Math.round(8 + 46 * (0.5 + 0.5 * Math.sin(k * 1.1 + phase[i])) * (0.4 + 0.6 * rnd())));
}
}
keys.push(row);
}
const RACE_END = (KEY_N - 1) * RACE_FRAME_MS;
let raceMs = 0;
let paused = false;
const cur = [];
const rank = [];
const slotY = [];
const alpha = [];
const glow = [];
let axisMax = 1;
for (let i = 0; i < ENTRY_N; i += 1) {
cur.push(0);
rank.push(i);
slotY.push(i);
alpha.push(0);
glow.push(0);
}
// 지금 시각의 값 — 앞뒤 키프레임 사이를 선형 보간
function sample(ms) {
const p = clamp(ms / RACE_FRAME_MS, 0, KEY_N - 1);
const k = Math.min(Math.floor(p), KEY_N - 2);
const f = p - k;
for (let i = 0; i < ENTRY_N; i += 1) {
cur[i] = lerp(keys[k][i], keys[k + 1][i], f);
}
}
// 매 걸음 정렬 — 값이 큰 순서로 순위를 다시 매기고, 순위가 오른 막대는 잠깐 빛난다
function reRank() {
const order = [];
for (let i = 0; i < ENTRY_N; i += 1) {
order.push(i);
}
order.sort(function (a, b) { return cur[b] - cur[a]; });
for (let r = 0; r < ENTRY_N; r += 1) {
const i = order[r];
if (r < rank[i] && r < TOP_N) {
glow[i] = 1;
}
rank[i] = r;
}
}
function topValue() {
let m = 1;
for (let i = 0; i < ENTRY_N; i += 1) {
m = Math.max(m, cur[i]);
}
return m;
}
function step() {
if (!paused) {
raceMs += FIXED_DT;
if (raceMs > RACE_END) {
raceMs = 0;
}
}
sample(raceMs);
reRank();
for (let i = 0; i < ENTRY_N; i += 1) {
const target = Math.min(rank[i], TOP_N);
slotY[i] += (target - slotY[i]) * RANK_LERP;
alpha[i] += ((rank[i] < TOP_N ? 1 : 0) - alpha[i]) * RANK_LERP;
glow[i] *= 0.95;
}
axisMax += (topValue() * 1.06 - axisMax) * SCALE_LERP;
// 축 기준값이 늦게 따라와도 1등 막대가 판 오른쪽 끝을 넘지 않게 아래를 받친다
axisMax = Math.max(axisMax, topValue());
}
function settle() {
sample(raceMs);
reRank();
for (let i = 0; i < ENTRY_N; i += 1) {
slotY[i] = Math.min(rank[i], TOP_N);
alpha[i] = rank[i] < TOP_N ? 1 : 0;
glow[i] = 0;
}
axisMax = topValue() * 1.06;
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(LABELS[f] + ' · ' + groupDigits(cur[f]) + ' · #' + (rank[f] + 1));
}
else {
say(TXT_IDLE);
}
}
function demoTick(_t) {
// 시연은 경주를 그대로 흘려 보낸다 — 순위가 뒤집히는 줄 바꿈이 이 항목의 동작이다
}
function draw() {
clearStage();
const span = BAR_X1 - BAR_X0;
for (let g = 1; g <= 4; g += 1) {
ctx.fillStyle = rgba(PAPER, 0.07);
ctx.fillRect(BAR_X0 + Math.round(span * g / 4), LANE_T - 6, 1, TOP_N * LANE_H + 4);
}
ctx.fillStyle = rgba(PAPER, 0.3);
ctx.fillRect(BAR_X0, LANE_T - 6, 1, TOP_N * LANE_H + 4);
// 큰 숫자 — 지금 몇 번째 키프레임 구간을 지나는지
const p = raceMs / RACE_FRAME_MS;
ctx.font = '800 40px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.12);
ctx.fillText(String(Math.min(Math.floor(p) + 1, KEY_N - 1)).padStart(2, '0') + ' / ' + String(KEY_N - 1).padStart(2, '0'), W - 16, H - 14);
// 아래 줄부터 그려 위로 치고 올라오는 막대가 겹칠 때 위에 보이게
const order = [];
for (let i = 0; i < ENTRY_N; i += 1) {
order.push(i);
}
order.sort(function (a, b) { return slotY[b] - slotY[a]; });
const f = focusIdx();
for (let n = 0; n < ENTRY_N; n += 1) {
const i = order[n];
if (alpha[i] < 0.02) {
continue;
}
const y = LANE_T + slotY[i] * LANE_H;
const len = cur[i] / axisMax * span;
const dim = f >= 0 && f !== i;
const a = alpha[i] * (dim ? 0.5 : 1);
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.45 * glow[i]), a);
hbarPath(ctx, BAR_X0, y, len, BAR_TH, 6);
ctx.fill();
if (glow[i] > 0.05) {
ctx.strokeStyle = rgba(PAPER, 0.8 * glow[i] * alpha[i]);
ctx.lineWidth = 2;
ctx.stroke();
}
if (i === f) {
ringStyle(i, alpha[i]);
hbarPath(ctx, BAR_X0, y, len, BAR_TH, 6);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
ctx.font = '600 11px sans-serif';
ctx.fillStyle = rgba(PAPER, a * 0.85);
ctx.fillText(LABELS[i], BAR_X0 - 8, y + BAR_TH / 2);
ctx.textAlign = 'left';
ctx.font = '700 11px sans-serif';
ctx.fillStyle = rgba(PAPER, a * 0.95);
ctx.fillText(groupDigits(cur[i]), BAR_X0 + len + 6, y + BAR_TH / 2);
}
}
function after() {
if (still) {
settle();
}
report();
draw();
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
}
}
// 화면 위 줄 → 그 줄에 있는 항목
function hitLane(_x, y) {
const r = Math.floor((y - LANE_T + 4) / LANE_H);
if (r < 0 || r >= TOP_N) {
return -1;
}
for (let i = 0; i < ENTRY_N; i += 1) {
if (rank[i] === r) {
return i;
}
}
return -1;
}
// ←/→ 는 순위 차례로 옮긴다 — 화면에 보이는 TOP_N 줄 안에서만
function moveRank(d) {
const f = focusIdx();
const r = f < 0 ? (d > 0 ? 0 : TOP_N - 1) : (Math.min(rank[f], TOP_N - 1) + d + TOP_N) % TOP_N;
for (let i = 0; i < ENTRY_N; i += 1) {
if (rank[i] === r) {
sel = i;
}
}
}
bindKeys(moveRank, after);
bindPointer(hitLane, after);
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); paused = !paused; after(); });
}
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); raceMs = 0; after(); });
}
if (still) {
raceMs = RACE_END;
}
sample(raceMs);
reRank();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarRaceRankSwap - vanilla 판(02-bar-chart-race-rank-swap) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarRaceRankSwap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarRaceRankSwap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 키프레임 하나에서 다음 키프레임까지 걸리는 시간(ms) — 그 사이 값은 직선으로 보간한다
const RACE_FRAME_MS = 420;
// 16ms 걸음마다 막대의 세로 자리가 새 순위와의 남은 거리 중 이 비율만큼 다가간다 (지수 평활)
const RANK_LERP = 0.12;
// 화면에 남기는 줄 수 — 정렬한 뒤 위에서 이만큼만 그린다
const TOP_N = 6;
const ENTRY_N = 9;
const KEY_N = 8;
const SEED = 4702;
const LANE_T = 60;
const LANE_H = 33;
const BAR_TH = 23;
const BAR_X0 = 84;
const BAR_X1 = 420;
const SCALE_LERP = 0.08;
const TXT_IDLE = '막대를 고르면 값과 순위를 읽어 줌';
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
// 키프레임 값 — 항목마다 시작 값과 빨라졌다 느려지는 위상을 따로 두어 순위가 엎치락뒤치락한다
const rnd = makeRng(SEED);
const keys: number[][] = [];
const phase: number[] = [];
for (let i = 0; i < ENTRY_N; i += 1) { phase.push(rnd() * TAU); }
for (let k = 0; k < KEY_N; k += 1) {
const row: number[] = [];
for (let i = 0; i < ENTRY_N; i += 1) {
if (k === 0) { row.push(Math.round(30 + rnd() * 50)); }
else { row.push(keys[k - 1][i] + Math.round(8 + 46 * (0.5 + 0.5 * Math.sin(k * 1.1 + phase[i])) * (0.4 + 0.6 * rnd()))); }
}
keys.push(row);
}
const RACE_END = (KEY_N - 1) * RACE_FRAME_MS;
let raceMs = 0;
let paused = false;
const cur: number[] = [];
const rank: number[] = [];
const slotY: number[] = [];
const alpha: number[] = [];
const glow: number[] = [];
let axisMax = 1;
for (let i = 0; i < ENTRY_N; i += 1) { cur.push(0); rank.push(i); slotY.push(i); alpha.push(0); glow.push(0); }
// 지금 시각의 값 — 앞뒤 키프레임 사이를 선형 보간
function sample(ms: number): void {
const p = clamp(ms / RACE_FRAME_MS, 0, KEY_N - 1);
const k = Math.min(Math.floor(p), KEY_N - 2);
const f = p - k;
for (let i = 0; i < ENTRY_N; i += 1) { cur[i] = lerp(keys[k][i], keys[k + 1][i], f); }
}
// 매 걸음 정렬 — 값이 큰 순서로 순위를 다시 매기고, 순위가 오른 막대는 잠깐 빛난다
function reRank(): void {
const order: number[] = [];
for (let i = 0; i < ENTRY_N; i += 1) { order.push(i); }
order.sort(function (a: number, b: number): number { return cur[b] - cur[a]; });
for (let r = 0; r < ENTRY_N; r += 1) {
const i = order[r];
if (r < rank[i] && r < TOP_N) { glow[i] = 1; }
rank[i] = r;
}
}
function topValue(): number {
let m = 1;
for (let i = 0; i < ENTRY_N; i += 1) { m = Math.max(m, cur[i]); }
return m;
}
function step(): void {
if (!paused) {
raceMs += FIXED_DT;
if (raceMs > RACE_END) { raceMs = 0; }
}
sample(raceMs);
reRank();
for (let i = 0; i < ENTRY_N; i += 1) {
const target = Math.min(rank[i], TOP_N);
slotY[i] += (target - slotY[i]) * RANK_LERP;
alpha[i] += ((rank[i] < TOP_N ? 1 : 0) - alpha[i]) * RANK_LERP;
glow[i] *= 0.95;
}
axisMax += (topValue() * 1.06 - axisMax) * SCALE_LERP;
// 축 기준값이 늦게 따라와도 1등 막대가 판 오른쪽 끝을 넘지 않게 아래를 받친다
axisMax = Math.max(axisMax, topValue());
}
function settle(): void {
sample(raceMs);
reRank();
for (let i = 0; i < ENTRY_N; i += 1) {
slotY[i] = Math.min(rank[i], TOP_N);
alpha[i] = rank[i] < TOP_N ? 1 : 0;
glow[i] = 0;
}
axisMax = topValue() * 1.06;
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(LABELS[f] + ' · ' + groupDigits(cur[f]) + ' · #' + (rank[f] + 1)); } else { say(TXT_IDLE); }
}
function demoTick(_t: number): void {
// 시연은 경주를 그대로 흘려 보낸다 — 순위가 뒤집히는 줄 바꿈이 이 항목의 동작이다
}
function draw(): void {
clearStage();
const span = BAR_X1 - BAR_X0;
for (let g = 1; g <= 4; g += 1) {
ctx.fillStyle = rgba(PAPER, 0.07);
ctx.fillRect(BAR_X0 + Math.round(span * g / 4), LANE_T - 6, 1, TOP_N * LANE_H + 4);
}
ctx.fillStyle = rgba(PAPER, 0.3);
ctx.fillRect(BAR_X0, LANE_T - 6, 1, TOP_N * LANE_H + 4);
// 큰 숫자 — 지금 몇 번째 키프레임 구간을 지나는지
const p = raceMs / RACE_FRAME_MS;
ctx.font = '800 40px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.12);
ctx.fillText(String(Math.min(Math.floor(p) + 1, KEY_N - 1)).padStart(2, '0') + ' / ' + String(KEY_N - 1).padStart(2, '0'), W - 16, H - 14);
// 아래 줄부터 그려 위로 치고 올라오는 막대가 겹칠 때 위에 보이게
const order: number[] = [];
for (let i = 0; i < ENTRY_N; i += 1) { order.push(i); }
order.sort(function (a: number, b: number): number { return slotY[b] - slotY[a]; });
const f = focusIdx();
for (let n = 0; n < ENTRY_N; n += 1) {
const i = order[n];
if (alpha[i] < 0.02) { continue; }
const y = LANE_T + slotY[i] * LANE_H;
const len = cur[i] / axisMax * span;
const dim = f >= 0 && f !== i;
const a = alpha[i] * (dim ? 0.5 : 1);
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.45 * glow[i]), a);
hbarPath(ctx, BAR_X0, y, len, BAR_TH, 6);
ctx.fill();
if (glow[i] > 0.05) {
ctx.strokeStyle = rgba(PAPER, 0.8 * glow[i] * alpha[i]);
ctx.lineWidth = 2;
ctx.stroke();
}
if (i === f) {
ringStyle(i, alpha[i]);
hbarPath(ctx, BAR_X0, y, len, BAR_TH, 6);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
ctx.font = '600 11px sans-serif';
ctx.fillStyle = rgba(PAPER, a * 0.85);
ctx.fillText(LABELS[i], BAR_X0 - 8, y + BAR_TH / 2);
ctx.textAlign = 'left';
ctx.font = '700 11px sans-serif';
ctx.fillStyle = rgba(PAPER, a * 0.95);
ctx.fillText(groupDigits(cur[i]), BAR_X0 + len + 6, y + BAR_TH / 2);
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
}
// 화면 위 줄 → 그 줄에 있는 항목
function hitLane(_x: number, y: number): number {
const r = Math.floor((y - LANE_T + 4) / LANE_H);
if (r < 0 || r >= TOP_N) { return -1; }
for (let i = 0; i < ENTRY_N; i += 1) { if (rank[i] === r) { return i; } }
return -1;
}
// ←/→ 는 순위 차례로 옮긴다 — 화면에 보이는 TOP_N 줄 안에서만
function moveRank(d: number): void {
const f = focusIdx();
const r = f < 0 ? (d > 0 ? 0 : TOP_N - 1) : (Math.min(rank[f], TOP_N - 1) + d + TOP_N) % TOP_N;
for (let i = 0; i < ENTRY_N; i += 1) { if (rank[i] === r) { sel = i; } }
}
bindKeys(moveRank, after);
bindPointer(hitLane, after);
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); paused = !paused; after(); }); }
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); raceMs = 0; after(); }); }
if (still) { raceMs = RACE_END; }
sample(raceMs);
reRank();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="키프레임 사이를 보간하며 매 걸음 다시 정렬해 순위가 바뀐 막대가 줄을 옮겨 달리는 막대 경주. ←/→ 로 순위 차례대로 막대를 고르고 Enter 로 고정한다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 값과 순위를 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="pause" type="button" aria-pressed="false">일시 정지</button>
<button className={cx("bc__btn")} data-role="restart" type="button">처음부터</button>
</div>
</div>
);
}02 · 순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주 — 측정 누적면적 42.181% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 매 걸음 정렬 — 값이 큰 순서로 순위를 다시 매기고, 순위가 오른 막대는 잠깐 빛난다
function reRank() {
const order = [];
for (let i = 0; i < ENTRY_N; i += 1) {
order.push(i);
}
order.sort(function (a, b) { return cur[b] - cur[a]; });
for (let r = 0; r < ENTRY_N; r += 1) {
const i = order[r];
if (r < rank[i] && r < TOP_N) {
glow[i] = 1;
}
rank[i] = r;
}
}
03아래 층부터 차례로 쌓여 오르는 누적 막대
여섯 막대마다 네 계열 값을 아래부터 더해 나간 누적합으로 층의 시작 높이를 정하고, 층 출발은 220ms·막대 출발은 40ms 씩 늦춰 520ms 동안 3제곱 이징 1 − (1 − t)³ 으로 채우며 층 사이에 2px 틈을 둔다. 이징이 앞쪽에 몰려 있어 520ms 가운데 279ms 에 90% 가 차고 마지막 막대의 맨 위 층까지 1,380ms 에 다 쌓이며, 「계열 3 숨기기」 를 누르면 그 층의 가중치가 0 으로 줄면서 위층이 함께 내려앉고 꼭대기 합계도 같은 누적합으로 다시 센다. 채널별 유입 합계 보고서에 붙일 판으로, 움직인 프레임이 22/23 으로 한 장 모자란 것은 다 쌓인 뒤 계열을 숨기기 전까지 잠시 서 있는 틈 때문으로 보인다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="계열 네 개를 누적합으로 쌓아 아래 층부터 시차를 두고 차오르는 누적 막대 그래프. ←/→ 로 막대를 고르면 이름과 합계를 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 합계를 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="replay" type="button">다시 쌓기</button>
<button class="bc__btn" data-role="hide" type="button" aria-pressed="false">계열 3 숨기기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 층 출발 시차 — 아래 층이 출발하고 이만큼 뒤에 바로 위 층이 출발한다
const SEGMENT_DELAY_MS = 220;
// 층 채우기 이징의 거듭제곱 — 1 − (1 − t)^p, 클수록 처음에 빨리 차고 끝에서 오래 머문다
const SEGMENT_EASE_POW = 3;
// 층과 층 사이 틈(px) — 같은 색이 붙어도 경계가 보이게
const STACK_GAP_PX = 2;
const FILL_MS = 520;
const COL_STAGGER_MS = 40;
const COL_N = 6;
const SERIES_N = 4;
const SEED = 4703;
const PLOT_L = 40;
const PLOT_R = 384;
const PLOT_T = 70;
const PLOT_B = 262;
const COL_GAP = 20;
const WEIGHT_LERP = 0.14;
const DEMO_LOOP = 2000;
const SERIES = ['계열 1', '계열 2', '계열 3', '계열 4'];
const SERIES_COL = [PAL[0], PAL[3], PAL[1], PAL[4]];
const TXT_IDLE = '막대를 고르면 합계를 읽어 줌';
const replayBtn = root.querySelector('[data-role="replay"]');
const hideBtn = root.querySelector('[data-role="hide"]');
const values = [];
for (let c = 0; c < COL_N; c += 1) {
values.push(seededValues(SEED + c * 7, SERIES_N, 8, 34));
}
const slotW = (PLOT_R - PLOT_L) / COL_N;
const barW = slotW - COL_GAP;
let buildMs = 0;
let hidden = -1;
const weight = [1, 1, 1, 1];
// 축 최댓값 — 가장 높은 합계를 20 단위로 올림
let maxTotal = 0;
for (let c = 0; c < COL_N; c += 1) {
let s = 0;
for (let k = 0; k < SERIES_N; k += 1) {
s += values[c][k];
}
maxTotal = Math.max(maxTotal, s);
}
const AXIS_MAX = Math.ceil(maxTotal / 20) * 20;
// 층 k 의 채움 정도(0~1) — 출발은 층마다 SEGMENT_DELAY_MS, 막대마다 COL_STAGGER_MS 씩 늦다
function fill(c, k) {
const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
}
// 지금 보이는 값 — 원래 값 × 채움 × 계열 표시 가중치
function shown(c, k) { return values[c][k] * fill(c, k) * weight[k]; }
// 누적합 — 층 k 의 시작 높이는 아래 층들의 보이는 값을 더한 것
function baseOf(c, k) {
let s = 0;
for (let j = 0; j < k; j += 1) {
s += shown(c, j);
}
return s;
}
function totalOf(c) { return baseOf(c, SERIES_N); }
const buildEnd = (SERIES_N - 1) * SEGMENT_DELAY_MS + (COL_N - 1) * COL_STAGGER_MS + FILL_MS;
function step() {
buildMs += FIXED_DT;
for (let k = 0; k < SERIES_N; k += 1) {
weight[k] += ((k === hidden ? 0 : 1) - weight[k]) * WEIGHT_LERP;
}
}
function settle() {
buildMs = Math.max(buildMs, buildEnd);
for (let k = 0; k < SERIES_N; k += 1) {
weight[k] = k === hidden ? 0 : 1;
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(LABELS[f] + ' · 합계 ' + groupDigits(totalOf(f)));
}
else {
say(TXT_IDLE);
}
}
function rebuild() { buildMs = 0; }
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 1650], function (k) {
if (k === 0) {
hidden = -1;
for (let j = 0; j < SERIES_N; j += 1) {
weight[j] = 1;
}
rebuild();
}
else {
hidden = 2;
}
report();
});
}
const unit = (PLOT_B - PLOT_T) / AXIS_MAX;
function draw() {
clearStage();
for (let g = 0; g <= AXIS_MAX; g += 20) {
const y = PLOT_B - g * unit;
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.32 : 0.08);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(String(g), PLOT_L - 8, y, 'right', 0.5);
}
const f = focusIdx();
for (let c = 0; c < COL_N; c += 1) {
const x = PLOT_L + c * slotW + COL_GAP / 2;
const dim = f >= 0 && f !== c;
let acc = 0;
let topK = -1;
for (let k = 0; k < SERIES_N; k += 1) {
if (shown(c, k) * unit > 0.5) {
topK = k;
}
}
for (let k = 0; k < SERIES_N; k += 1) {
const h = shown(c, k) * unit;
if (h <= 0.5) {
continue;
}
// 틈은 층이 틈보다 클 때만 다 연다 — 막 출발한 얇은 층이 틈에 묻히지 않게
if (acc > 0) {
acc += STACK_GAP_PX * clamp(h / STACK_GAP_PX, 0, 1);
}
ctx.fillStyle = rgba(SERIES_COL[k], dim ? 0.45 : 1);
barPath(ctx, x, PLOT_B - acc, barW, h, k === topK ? 5 : 0);
ctx.fill();
acc += h;
}
if (c === f && acc > 0) {
ringStyle(c, 1);
ctx.strokeRect(x - 2, PLOT_B - acc - 2, barW + 4, acc + 2);
ctx.setLineDash([]);
}
// 꼭대기 합계 — 층이 차오르는 만큼 같이 세어 올린다
const total = totalOf(c);
if (total > 0.5) {
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.95);
ctx.fillText(groupDigits(total), x + barW / 2, PLOT_B - acc - 5);
}
axisText(LABELS[c], x + barW / 2, PLOT_B + 14, 'center', c === f ? 0.95 : 0.55);
}
// 범례 — 숨긴 계열은 흐리게
for (let k = SERIES_N - 1; k >= 0; k -= 1) {
const y = PLOT_T + 30 + (SERIES_N - 1 - k) * 22;
ctx.fillStyle = rgba(SERIES_COL[k], 0.25 + 0.75 * weight[k]);
ctx.fillRect(404, y - 5, 10, 10);
ctx.font = '500 11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.3 + 0.55 * weight[k]);
ctx.fillText(SERIES[k], 420, y);
}
}
function after() {
if (still) {
settle();
}
report();
draw();
if (hideBtn) {
hideBtn.setAttribute('aria-pressed', hidden === 2 ? 'true' : 'false');
}
}
function hitCol(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 24) {
return -1;
}
const c = Math.floor((x - PLOT_L) / slotW);
return c >= 0 && c < COL_N ? c : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, COL_N); }, after);
bindPointer(hitCol, after);
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); rebuild(); after(); });
}
if (hideBtn) {
hideBtn.addEventListener('click', function () { wake(); hidden = hidden === 2 ? -1 : 2; after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarStackedSegmentCascade - vanilla 판(03-bar-chart-stacked-segment-cascade) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarStackedSegmentCascade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarStackedSegmentCascade() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 층 출발 시차 — 아래 층이 출발하고 이만큼 뒤에 바로 위 층이 출발한다
const SEGMENT_DELAY_MS = 220;
// 층 채우기 이징의 거듭제곱 — 1 − (1 − t)^p, 클수록 처음에 빨리 차고 끝에서 오래 머문다
const SEGMENT_EASE_POW = 3;
// 층과 층 사이 틈(px) — 같은 색이 붙어도 경계가 보이게
const STACK_GAP_PX = 2;
const FILL_MS = 520;
const COL_STAGGER_MS = 40;
const COL_N = 6;
const SERIES_N = 4;
const SEED = 4703;
const PLOT_L = 40;
const PLOT_R = 384;
const PLOT_T = 70;
const PLOT_B = 262;
const COL_GAP = 20;
const WEIGHT_LERP = 0.14;
const DEMO_LOOP = 2000;
const SERIES = ['계열 1', '계열 2', '계열 3', '계열 4'];
const SERIES_COL: RGB[] = [PAL[0], PAL[3], PAL[1], PAL[4]];
const TXT_IDLE = '막대를 고르면 합계를 읽어 줌';
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const hideBtn = root.querySelector('[data-role="hide"]') as HTMLButtonElement | null;
const values: number[][] = [];
for (let c = 0; c < COL_N; c += 1) { values.push(seededValues(SEED + c * 7, SERIES_N, 8, 34)); }
const slotW = (PLOT_R - PLOT_L) / COL_N;
const barW = slotW - COL_GAP;
let buildMs = 0;
let hidden = -1;
const weight: number[] = [1, 1, 1, 1];
// 축 최댓값 — 가장 높은 합계를 20 단위로 올림
let maxTotal = 0;
for (let c = 0; c < COL_N; c += 1) { let s = 0; for (let k = 0; k < SERIES_N; k += 1) { s += values[c][k]; } maxTotal = Math.max(maxTotal, s); }
const AXIS_MAX = Math.ceil(maxTotal / 20) * 20;
// 층 k 의 채움 정도(0~1) — 출발은 층마다 SEGMENT_DELAY_MS, 막대마다 COL_STAGGER_MS 씩 늦다
function fill(c: number, k: number): number {
const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
}
// 지금 보이는 값 — 원래 값 × 채움 × 계열 표시 가중치
function shown(c: number, k: number): number { return values[c][k] * fill(c, k) * weight[k]; }
// 누적합 — 층 k 의 시작 높이는 아래 층들의 보이는 값을 더한 것
function baseOf(c: number, k: number): number {
let s = 0;
for (let j = 0; j < k; j += 1) { s += shown(c, j); }
return s;
}
function totalOf(c: number): number { return baseOf(c, SERIES_N); }
const buildEnd = (SERIES_N - 1) * SEGMENT_DELAY_MS + (COL_N - 1) * COL_STAGGER_MS + FILL_MS;
function step(): void {
buildMs += FIXED_DT;
for (let k = 0; k < SERIES_N; k += 1) { weight[k] += ((k === hidden ? 0 : 1) - weight[k]) * WEIGHT_LERP; }
}
function settle(): void {
buildMs = Math.max(buildMs, buildEnd);
for (let k = 0; k < SERIES_N; k += 1) { weight[k] = k === hidden ? 0 : 1; }
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(LABELS[f] + ' · 합계 ' + groupDigits(totalOf(f))); } else { say(TXT_IDLE); }
}
function rebuild(): void { buildMs = 0; }
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 1650], function (k: number): void {
if (k === 0) { hidden = -1; for (let j = 0; j < SERIES_N; j += 1) { weight[j] = 1; } rebuild(); }
else { hidden = 2; }
report();
});
}
const unit = (PLOT_B - PLOT_T) / AXIS_MAX;
function draw(): void {
clearStage();
for (let g = 0; g <= AXIS_MAX; g += 20) {
const y = PLOT_B - g * unit;
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.32 : 0.08);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(String(g), PLOT_L - 8, y, 'right', 0.5);
}
const f = focusIdx();
for (let c = 0; c < COL_N; c += 1) {
const x = PLOT_L + c * slotW + COL_GAP / 2;
const dim = f >= 0 && f !== c;
let acc = 0;
let topK = -1;
for (let k = 0; k < SERIES_N; k += 1) { if (shown(c, k) * unit > 0.5) { topK = k; } }
for (let k = 0; k < SERIES_N; k += 1) {
const h = shown(c, k) * unit;
if (h <= 0.5) { continue; }
// 틈은 층이 틈보다 클 때만 다 연다 — 막 출발한 얇은 층이 틈에 묻히지 않게
if (acc > 0) { acc += STACK_GAP_PX * clamp(h / STACK_GAP_PX, 0, 1); }
ctx.fillStyle = rgba(SERIES_COL[k], dim ? 0.45 : 1);
barPath(ctx, x, PLOT_B - acc, barW, h, k === topK ? 5 : 0);
ctx.fill();
acc += h;
}
if (c === f && acc > 0) {
ringStyle(c, 1);
ctx.strokeRect(x - 2, PLOT_B - acc - 2, barW + 4, acc + 2);
ctx.setLineDash([]);
}
// 꼭대기 합계 — 층이 차오르는 만큼 같이 세어 올린다
const total = totalOf(c);
if (total > 0.5) {
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.95);
ctx.fillText(groupDigits(total), x + barW / 2, PLOT_B - acc - 5);
}
axisText(LABELS[c], x + barW / 2, PLOT_B + 14, 'center', c === f ? 0.95 : 0.55);
}
// 범례 — 숨긴 계열은 흐리게
for (let k = SERIES_N - 1; k >= 0; k -= 1) {
const y = PLOT_T + 30 + (SERIES_N - 1 - k) * 22;
ctx.fillStyle = rgba(SERIES_COL[k], 0.25 + 0.75 * weight[k]);
ctx.fillRect(404, y - 5, 10, 10);
ctx.font = '500 11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.3 + 0.55 * weight[k]);
ctx.fillText(SERIES[k], 420, y);
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (hideBtn) { hideBtn.setAttribute('aria-pressed', hidden === 2 ? 'true' : 'false'); }
}
function hitCol(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 24) { return -1; }
const c = Math.floor((x - PLOT_L) / slotW);
return c >= 0 && c < COL_N ? c : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, COL_N); }, after);
bindPointer(hitCol, after);
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); rebuild(); after(); }); }
if (hideBtn) { hideBtn.addEventListener('click', function (): void { wake(); hidden = hidden === 2 ? -1 : 2; after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="계열 네 개를 누적합으로 쌓아 아래 층부터 시차를 두고 차오르는 누적 막대 그래프. ←/→ 로 막대를 고르면 이름과 합계를 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 합계를 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="replay" type="button">다시 쌓기</button>
<button className={cx("bc__btn")} data-role="hide" type="button" aria-pressed="false">계열 3 숨기기</button>
</div>
</div>
);
}03 · 아래 층부터 차례로 쌓여 오르는 누적 막대 — 측정 누적면적 18.397% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 층 k 의 채움 정도(0~1) — 출발은 층마다 SEGMENT_DELAY_MS, 막대마다 COL_STAGGER_MS 씩 늦다
function fill(c, k) {
const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
}
// 지금 보이는 값 — 원래 값 × 채움 × 계열 표시 가중치
function shown(c, k) { return values[c][k] * fill(c, k) * weight[k]; }
// 누적합 — 층 k 의 시작 높이는 아래 층들의 보이는 값을 더한 것
function baseOf(c, k) {
let s = 0;
for (let j = 0; j < k; j += 1) {
s += shown(c, j);
}
return s;
}
function totalOf(c) { return baseOf(c, SERIES_N); }
04라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대
막대 끝 숫자의 폭을 measureText 로 재서 양옆 8px 여백까지 막대 길이 안에 들어가는지 보고, 막대 색의 상대 휘도로 밝은 글자와 짙은 글자의 대비를 둘 다 계산해 나은 쪽이 CONTRAST_MIN 4.5 이상일 때만 막대 안쪽에 그 색으로 쓴다. 회보라색인 항목 D 의 막대 (128, 120, 150) 는 밝은 글자 3.86·짙은 글자 3.95 로 둘 다 4.5 에 못 미쳐서, 첫 데이터에서 막대가 234px 로 넉넉한데도 「15,940」 이 막대 바깥에 붙는다. 항목 이름이 긴 설문 결과처럼 막대 길이가 제각각인 그래프에 쓰며, 누적면적은 16.5% 로 아홉 가운데 가장 작다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="값 글자 폭과 막대 색의 대비를 재서 글자를 막대 안쪽이나 바깥에 두는 가로 막대 그래프. ←/→ 로 막대를 고르면 값과 글자 자리를 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 글자 자리를 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="data" type="button">새 데이터</button>
<button class="bc__btn" data-role="shrink" type="button" aria-pressed="false">막대 줄이기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 글자 양옆 여백(px) — 막대 길이에서 이만큼을 빼고도 값 글자가 들어가야 안쪽에 둔다
const LABEL_PAD_PX = 8;
// 막대 색과 글자 색의 대비 하한 — (밝은 쪽 휘도 + 0.05) / (어두운 쪽 휘도 + 0.05). 두 후보 모두 못 넘으면 바깥에 둔다
const CONTRAST_MIN = 4.5;
const BAR_THICKNESS_PX = 22;
const ROW_N = 7;
const SEED = 4704;
const ROW_T = 60;
const ROW_H = 30;
const BAR_X0 = 70;
const BAR_X1 = 452;
const AXIS_MAX = 26000;
const LEN_LERP = 0.12;
const LABEL_LERP = 0.2;
const DEMO_LOOP = 2000;
const TXT_IDLE = '막대를 고르면 글자 자리를 읽어 줌';
const TXT_IN = '안쪽';
const TXT_OUT = '바깥';
const dataBtn = root.querySelector('[data-role="data"]');
const shrinkBtn = root.querySelector('[data-role="shrink"]');
// 막대 색 — 밝은 파스텔·중간 톤·짙은 톤을 섞어 글자 색 고르기가 막대마다 달라지게
const ROW_COL = [[126, 190, 255], [76, 84, 150], [255, 164, 190], [128, 120, 150], [46, 104, 112], [244, 226, 140], [112, 96, 176]];
const LIGHT_TEXT = PAPER;
const DARK_TEXT = INK;
// 상대 휘도 — sRGB 채널을 선형으로 펴서 0.2126·0.7152·0.0722 로 섞는다
function channel(v) {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(c) { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// 자리 정하기 — 글자 폭 + 양옆 여백이 막대에 들어가고, 두 글자 색 중 나은 쪽이 CONTRAST_MIN 을 넘으면 안쪽
function placeLabel(text, len, bar) {
ctx.font = '700 12px sans-serif';
const width = ctx.measureText(text).width;
const light = contrast(bar, LIGHT_TEXT);
const dark = contrast(bar, DARK_TEXT);
const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
const ratio = Math.max(light, dark);
const fits = width + LABEL_PAD_PX * 2 <= len;
return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
}
const sets = [seededValues(SEED, ROW_N, 900, 25000), seededValues(SEED + 1, ROW_N, 900, 25000), seededValues(SEED + 2, ROW_N, 900, 25000)];
let setIdx = 0;
let shrink = false;
const len = [];
const labelX = [];
const inK = [];
for (let i = 0; i < ROW_N; i += 1) {
len.push(0);
labelX.push(BAR_X0);
inK.push(0);
}
function target(i) { return sets[setIdx][i] * (shrink ? 0.3 : 1); }
function lenOf(v) { return v / AXIS_MAX * (BAR_X1 - BAR_X0); }
// 글자 자리 목표 — 안쪽이면 막대 끝에서 여백만큼 안, 바깥이면 막대 끝에서 여백만큼 밖
function labelGoal(p, l) { return p.inside ? BAR_X0 + l - LABEL_PAD_PX - p.width : BAR_X0 + l + LABEL_PAD_PX; }
function step() {
for (let i = 0; i < ROW_N; i += 1) {
len[i] += (lenOf(target(i)) - len[i]) * LEN_LERP;
const p = placeLabel(groupDigits(sets[setIdx][i] * (shrink ? 0.3 : 1)), len[i], ROW_COL[i]);
labelX[i] += (labelGoal(p, len[i]) - labelX[i]) * LABEL_LERP;
inK[i] += ((p.inside ? 1 : 0) - inK[i]) * LABEL_LERP;
}
}
function settle() {
for (let i = 0; i < ROW_N; i += 1) {
len[i] = lenOf(target(i));
const p = placeLabel(groupDigits(target(i)), len[i], ROW_COL[i]);
labelX[i] = labelGoal(p, len[i]);
inK[i] = p.inside ? 1 : 0;
}
}
function report() {
const f = focusIdx();
if (f < 0) {
say(TXT_IDLE);
return;
}
const p = placeLabel(groupDigits(target(f)), lenOf(target(f)), ROW_COL[f]);
say(LABELS[f] + ' · ' + groupDigits(target(f)) + ' · ' + (p.inside ? TXT_IN : TXT_OUT) + ' ' + p.ratio.toFixed(1) + ':1');
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 900, 1600], function (k) {
setIdx = k;
shrink = false;
report();
});
}
function draw() {
clearStage();
const span = BAR_X1 - BAR_X0;
for (let g = 0; g <= 5; g += 1) {
const x = BAR_X0 + Math.round(span * g / 5);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.3 : 0.07);
ctx.fillRect(x, ROW_T - 6, 1, ROW_N * ROW_H + 2);
axisText(groupDigits(AXIS_MAX * g / 5), x, ROW_T + ROW_N * ROW_H + 8, 'center', 0.45);
}
const f = focusIdx();
for (let i = 0; i < ROW_N; i += 1) {
const y = ROW_T + i * ROW_H;
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(ROW_COL[i], dim ? 0.5 : 1);
hbarPath(ctx, BAR_X0, y, len[i], BAR_THICKNESS_PX, 5);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
axisText(LABELS[i], BAR_X0 - 8, y + BAR_THICKNESS_PX / 2, 'right', i === f ? 0.95 : 0.6);
const text = groupDigits(sets[setIdx][i] * (shrink ? 0.3 : 1));
const p = placeLabel(text, len[i], ROW_COL[i]);
// 안쪽에 못 들어간 막대 — 글자가 들어갔을 자리를 점선 상자로 보여 준다(폭 + 양옆 여백)
if (!p.inside && len[i] > 6) {
ctx.strokeStyle = rgba(PAPER, 0.35);
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
ctx.strokeRect(BAR_X0 + len[i] - p.width - LABEL_PAD_PX * 2, y + 3, p.width + LABEL_PAD_PX * 2, BAR_THICKNESS_PX - 6);
ctx.setLineDash([]);
}
// 글자 색 — 바깥(무대 위 밝은 글자)과 안쪽(대비로 고른 색) 사이를 inK 로 섞는다
ctx.font = '700 12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(mix(PAPER, p.text, inK[i]), dim ? 0.5 : 1);
ctx.fillText(text, labelX[i], y + BAR_THICKNESS_PX / 2 + 0.5);
}
}
function after() {
if (still) {
settle();
}
report();
draw();
if (shrinkBtn) {
shrinkBtn.setAttribute('aria-pressed', shrink ? 'true' : 'false');
}
}
function hitRow(_x, y) {
const i = Math.floor((y - ROW_T + (ROW_H - BAR_THICKNESS_PX) / 2) / ROW_H);
return i >= 0 && i < ROW_N ? i : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, ROW_N); }, after);
bindPointer(hitRow, after);
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); setIdx = (setIdx + 1) % sets.length; after(); });
}
if (shrinkBtn) {
shrinkBtn.addEventListener('click', function () { wake(); shrink = !shrink; after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarHorizontalLabelFit - vanilla 판(04-bar-chart-horizontal-label-fit) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarHorizontalLabelFit.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarHorizontalLabelFit() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 글자 양옆 여백(px) — 막대 길이에서 이만큼을 빼고도 값 글자가 들어가야 안쪽에 둔다
const LABEL_PAD_PX = 8;
// 막대 색과 글자 색의 대비 하한 — (밝은 쪽 휘도 + 0.05) / (어두운 쪽 휘도 + 0.05). 두 후보 모두 못 넘으면 바깥에 둔다
const CONTRAST_MIN = 4.5;
const BAR_THICKNESS_PX = 22;
const ROW_N = 7;
const SEED = 4704;
const ROW_T = 60;
const ROW_H = 30;
const BAR_X0 = 70;
const BAR_X1 = 452;
const AXIS_MAX = 26000;
const LEN_LERP = 0.12;
const LABEL_LERP = 0.2;
const DEMO_LOOP = 2000;
const TXT_IDLE = '막대를 고르면 글자 자리를 읽어 줌';
const TXT_IN = '안쪽';
const TXT_OUT = '바깥';
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
const shrinkBtn = root.querySelector('[data-role="shrink"]') as HTMLButtonElement | null;
// 막대 색 — 밝은 파스텔·중간 톤·짙은 톤을 섞어 글자 색 고르기가 막대마다 달라지게
const ROW_COL: RGB[] = [[126, 190, 255], [76, 84, 150], [255, 164, 190], [128, 120, 150], [46, 104, 112], [244, 226, 140], [112, 96, 176]];
const LIGHT_TEXT: RGB = PAPER;
const DARK_TEXT: RGB = INK;
// 상대 휘도 — sRGB 채널을 선형으로 펴서 0.2126·0.7152·0.0722 로 섞는다
function channel(v: number): number {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(c: RGB): number { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
function contrast(a: RGB, b: RGB): number {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
type Pick = { inside: boolean; text: RGB; ratio: number; width: number };
// 자리 정하기 — 글자 폭 + 양옆 여백이 막대에 들어가고, 두 글자 색 중 나은 쪽이 CONTRAST_MIN 을 넘으면 안쪽
function placeLabel(text: string, len: number, bar: RGB): Pick {
ctx.font = '700 12px sans-serif';
const width = ctx.measureText(text).width;
const light = contrast(bar, LIGHT_TEXT);
const dark = contrast(bar, DARK_TEXT);
const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
const ratio = Math.max(light, dark);
const fits = width + LABEL_PAD_PX * 2 <= len;
return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
}
const sets: number[][] = [seededValues(SEED, ROW_N, 900, 25000), seededValues(SEED + 1, ROW_N, 900, 25000), seededValues(SEED + 2, ROW_N, 900, 25000)];
let setIdx = 0;
let shrink = false;
const len: number[] = [];
const labelX: number[] = [];
const inK: number[] = [];
for (let i = 0; i < ROW_N; i += 1) { len.push(0); labelX.push(BAR_X0); inK.push(0); }
function target(i: number): number { return sets[setIdx][i] * (shrink ? 0.3 : 1); }
function lenOf(v: number): number { return v / AXIS_MAX * (BAR_X1 - BAR_X0); }
// 글자 자리 목표 — 안쪽이면 막대 끝에서 여백만큼 안, 바깥이면 막대 끝에서 여백만큼 밖
function labelGoal(p: Pick, l: number): number { return p.inside ? BAR_X0 + l - LABEL_PAD_PX - p.width : BAR_X0 + l + LABEL_PAD_PX; }
function step(): void {
for (let i = 0; i < ROW_N; i += 1) {
len[i] += (lenOf(target(i)) - len[i]) * LEN_LERP;
const p = placeLabel(groupDigits(sets[setIdx][i] * (shrink ? 0.3 : 1)), len[i], ROW_COL[i]);
labelX[i] += (labelGoal(p, len[i]) - labelX[i]) * LABEL_LERP;
inK[i] += ((p.inside ? 1 : 0) - inK[i]) * LABEL_LERP;
}
}
function settle(): void {
for (let i = 0; i < ROW_N; i += 1) {
len[i] = lenOf(target(i));
const p = placeLabel(groupDigits(target(i)), len[i], ROW_COL[i]);
labelX[i] = labelGoal(p, len[i]);
inK[i] = p.inside ? 1 : 0;
}
}
function report(): void {
const f = focusIdx();
if (f < 0) { say(TXT_IDLE); return; }
const p = placeLabel(groupDigits(target(f)), lenOf(target(f)), ROW_COL[f]);
say(LABELS[f] + ' · ' + groupDigits(target(f)) + ' · ' + (p.inside ? TXT_IN : TXT_OUT) + ' ' + p.ratio.toFixed(1) + ':1');
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 900, 1600], function (k: number): void {
setIdx = k;
shrink = false;
report();
});
}
function draw(): void {
clearStage();
const span = BAR_X1 - BAR_X0;
for (let g = 0; g <= 5; g += 1) {
const x = BAR_X0 + Math.round(span * g / 5);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.3 : 0.07);
ctx.fillRect(x, ROW_T - 6, 1, ROW_N * ROW_H + 2);
axisText(groupDigits(AXIS_MAX * g / 5), x, ROW_T + ROW_N * ROW_H + 8, 'center', 0.45);
}
const f = focusIdx();
for (let i = 0; i < ROW_N; i += 1) {
const y = ROW_T + i * ROW_H;
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(ROW_COL[i], dim ? 0.5 : 1);
hbarPath(ctx, BAR_X0, y, len[i], BAR_THICKNESS_PX, 5);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
axisText(LABELS[i], BAR_X0 - 8, y + BAR_THICKNESS_PX / 2, 'right', i === f ? 0.95 : 0.6);
const text = groupDigits(sets[setIdx][i] * (shrink ? 0.3 : 1));
const p = placeLabel(text, len[i], ROW_COL[i]);
// 안쪽에 못 들어간 막대 — 글자가 들어갔을 자리를 점선 상자로 보여 준다(폭 + 양옆 여백)
if (!p.inside && len[i] > 6) {
ctx.strokeStyle = rgba(PAPER, 0.35);
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
ctx.strokeRect(BAR_X0 + len[i] - p.width - LABEL_PAD_PX * 2, y + 3, p.width + LABEL_PAD_PX * 2, BAR_THICKNESS_PX - 6);
ctx.setLineDash([]);
}
// 글자 색 — 바깥(무대 위 밝은 글자)과 안쪽(대비로 고른 색) 사이를 inK 로 섞는다
ctx.font = '700 12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(mix(PAPER, p.text, inK[i]), dim ? 0.5 : 1);
ctx.fillText(text, labelX[i], y + BAR_THICKNESS_PX / 2 + 0.5);
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (shrinkBtn) { shrinkBtn.setAttribute('aria-pressed', shrink ? 'true' : 'false'); }
}
function hitRow(_x: number, y: number): number {
const i = Math.floor((y - ROW_T + (ROW_H - BAR_THICKNESS_PX) / 2) / ROW_H);
return i >= 0 && i < ROW_N ? i : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, ROW_N); }, after);
bindPointer(hitRow, after);
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); setIdx = (setIdx + 1) % sets.length; after(); }); }
if (shrinkBtn) { shrinkBtn.addEventListener('click', function (): void { wake(); shrink = !shrink; after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="값 글자 폭과 막대 색의 대비를 재서 글자를 막대 안쪽이나 바깥에 두는 가로 막대 그래프. ←/→ 로 막대를 고르면 값과 글자 자리를 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 글자 자리를 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="data" type="button">새 데이터</button>
<button className={cx("bc__btn")} data-role="shrink" type="button" aria-pressed="false">막대 줄이기</button>
</div>
</div>
);
}04 · 라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대 — 측정 누적면적 16.487% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 상대 휘도 — sRGB 채널을 선형으로 펴서 0.2126·0.7152·0.0722 로 섞는다
function channel(v) {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(c) { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// 자리 정하기 — 글자 폭 + 양옆 여백이 막대에 들어가고, 두 글자 색 중 나은 쪽이 CONTRAST_MIN 을 넘으면 안쪽
function placeLabel(text, len, bar) {
ctx.font = '700 12px sans-serif';
const width = ctx.measureText(text).width;
const light = contrast(bar, LIGHT_TEXT);
const dark = contrast(bar, DARK_TEXT);
const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
const ratio = Math.max(light, dark);
const fits = width + LABEL_PAD_PX * 2 <= len;
return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
}
05값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축
막대 최댓값을 다섯 칸(TICK_TARGET_COUNT)으로 나눈 대략의 간격을 10의 거듭제곱으로 눌러 1·2·5·10 가운데 그 이상인 첫 값으로 올리고, 축 끝이 바뀔 때만 600ms 동안 옛 눈금은 흐려지고 새 눈금이 들어오며 막대 높이도 같은 최댓값으로 나눠 함께 줄어든다. 「값 늘리기」 를 누르면 최댓값이 46 → 118 → 405 → 922 로 커지는 동안 간격은 10 → 50 → 100 → 200, 축 끝은 50 → 150 → 500 → 1,000 으로 다시 잡힌다. 실시간으로 값이 커지는 방문자 그래프에 맞는 판이고, 강도 평균 140.5 는 03 다음으로 높다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="값이 커지면 1·2·5 단위로 눈금을 다시 골라 축과 막대가 함께 줄어드는 막대 그래프. ←/→ 로 막대를 고르면 이름과 값을 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 값을 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="grow" type="button">값 늘리기</button>
<button class="bc__btn" data-role="reset" type="button">처음 값</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 눈금을 대략 몇 칸으로 나눌지 — 최댓값 ÷ 이 수가 「대략의 간격」
const TICK_TARGET_COUNT = 5;
// 10의 거듭제곱으로 나눈 간격을 이 중 가장 가까운 값으로 올린다 (10 은 다음 자릿수의 1)
const NICE_STEPS = [1, 2, 5, 10];
// 축 최댓값이 바뀔 때 옛 눈금이 흐려지고 새 눈금이 들어오는 시간(ms)
const RESCALE_MS = 600;
const BAR_N = 7;
const SEED = 4705;
const PLOT_L = 52;
const PLOT_R = 462;
const PLOT_T = 72;
const PLOT_B = 262;
const BAR_GAP = 16;
const VALUE_LERP = 0.14;
const DEMO_LOOP = 2000;
const GROWTH = [1, 2.8, 8.5, 21];
const TXT_IDLE = '막대를 고르면 값을 읽어 줌';
const TXT_STEP = '눈금 간격 ';
const growBtn = root.querySelector('[data-role="grow"]');
const resetBtn = root.querySelector('[data-role="reset"]');
const baseVals = seededValues(SEED, BAR_N, 12, 46);
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
let level = 0;
const goalVals = baseVals.slice();
const vals = baseVals.slice();
// 1·2·5 계단 — 대략의 간격을 10의 거듭제곱으로 정규화해 NICE_STEPS 가운데 그 이상인 첫 값으로 올린다
function niceStep(maxValue) {
const raw = maxValue / TICK_TARGET_COUNT;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
for (let i = 0; i < NICE_STEPS.length; i += 1) {
if (norm <= NICE_STEPS[i] + 1e-9) {
return NICE_STEPS[i] * mag;
}
}
return 10 * mag;
}
function niceMax(maxValue) {
const s = niceStep(maxValue);
return Math.ceil(maxValue / s - 1e-9) * s;
}
function maxOf(a) {
let m = 0;
for (let i = 0; i < a.length; i += 1) {
m = Math.max(m, a[i]);
}
return m;
}
let fromAxis = { max: niceMax(maxOf(goalVals)), step: niceStep(maxOf(goalVals)) };
let toAxis = fromAxis;
let rescaleMs = RESCALE_MS;
function shownMax() {
const e = smooth(rescaleMs / RESCALE_MS);
return lerp(fromAxis.max, toAxis.max, e);
}
// 목표 값이 바뀌면 축을 다시 고르고, 최댓값이 달라졌을 때만 RESCALE_MS 짜리 보간을 시작한다
function setGoal(lv) {
level = lv;
for (let i = 0; i < BAR_N; i += 1) {
goalVals[i] = Math.round(baseVals[i] * GROWTH[level] * (1 + 0.18 * ((i * 7 + level * 3) % 5) / 4));
}
const m = maxOf(goalVals);
const next = { max: niceMax(m), step: niceStep(m) };
if (next.max !== toAxis.max) {
fromAxis = { max: shownMax(), step: toAxis.step };
toAxis = next;
rescaleMs = 0;
}
}
function step() {
rescaleMs = Math.min(RESCALE_MS, rescaleMs + FIXED_DT);
for (let i = 0; i < BAR_N; i += 1) {
vals[i] += (goalVals[i] - vals[i]) * VALUE_LERP;
}
}
function settle() {
rescaleMs = RESCALE_MS;
fromAxis = toAxis;
for (let i = 0; i < BAR_N; i += 1) {
vals[i] = goalVals[i];
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(LABELS[f] + ' · ' + groupDigits(goalVals[f]));
}
else {
say(TXT_IDLE);
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 700, 1200, 1700], function (k) {
setGoal(k);
report();
});
}
// 한 벌의 눈금 — 간격 step 으로 0 부터 axisMax 까지, 지금 보이는 최댓값 sm 기준 높이에 그린다
function ticks(ax, sm, a) {
if (a < 0.02) {
return;
}
for (let v = 0; v <= ax.max + 1e-9; v += ax.step) {
const y = PLOT_B - v / sm * (PLOT_B - PLOT_T);
if (y < PLOT_T - 8) {
break;
}
ctx.fillStyle = rgba(PAPER, (v === 0 ? 0.32 : 0.1) * a);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(groupDigits(v), PLOT_L - 8, y, 'right', 0.6 * a);
}
}
function draw() {
clearStage();
const sm = shownMax();
const e = smooth(rescaleMs / RESCALE_MS);
ticks(fromAxis, sm, 1 - e);
ticks(toAxis, sm, e);
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const h = vals[i] / sm * (PLOT_B - PLOT_T);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(PAL[i], dim ? 0.5 : 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(groupDigits(vals[i]), x + barW / 2, PLOT_B - h - 4);
axisText(LABELS[i], x + barW / 2, PLOT_B + 14, 'center', i === f ? 0.95 : 0.55);
}
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.7);
ctx.fillText(TXT_STEP + groupDigits(toAxis.step) + ' · max ' + groupDigits(toAxis.max), PLOT_R, PLOT_T - 16);
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function hitBar(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 24) {
return -1;
}
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (growBtn) {
growBtn.addEventListener('click', function () { wake(); setGoal(Math.min(level + 1, GROWTH.length - 1)); after(); });
}
if (resetBtn) {
resetBtn.addEventListener('click', function () { wake(); setGoal(0); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarAxisNiceTickRescale - vanilla 판(05-bar-chart-axis-nice-tick-rescale) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarAxisNiceTickRescale.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarAxisNiceTickRescale() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 눈금을 대략 몇 칸으로 나눌지 — 최댓값 ÷ 이 수가 「대략의 간격」
const TICK_TARGET_COUNT = 5;
// 10의 거듭제곱으로 나눈 간격을 이 중 가장 가까운 값으로 올린다 (10 은 다음 자릿수의 1)
const NICE_STEPS = [1, 2, 5, 10];
// 축 최댓값이 바뀔 때 옛 눈금이 흐려지고 새 눈금이 들어오는 시간(ms)
const RESCALE_MS = 600;
const BAR_N = 7;
const SEED = 4705;
const PLOT_L = 52;
const PLOT_R = 462;
const PLOT_T = 72;
const PLOT_B = 262;
const BAR_GAP = 16;
const VALUE_LERP = 0.14;
const DEMO_LOOP = 2000;
const GROWTH = [1, 2.8, 8.5, 21];
const TXT_IDLE = '막대를 고르면 값을 읽어 줌';
const TXT_STEP = '눈금 간격 ';
const growBtn = root.querySelector('[data-role="grow"]') as HTMLButtonElement | null;
const resetBtn = root.querySelector('[data-role="reset"]') as HTMLButtonElement | null;
const baseVals: number[] = seededValues(SEED, BAR_N, 12, 46);
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
let level = 0;
const goalVals: number[] = baseVals.slice();
const vals: number[] = baseVals.slice();
// 1·2·5 계단 — 대략의 간격을 10의 거듭제곱으로 정규화해 NICE_STEPS 가운데 그 이상인 첫 값으로 올린다
function niceStep(maxValue: number): number {
const raw = maxValue / TICK_TARGET_COUNT;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
for (let i = 0; i < NICE_STEPS.length; i += 1) {
if (norm <= NICE_STEPS[i] + 1e-9) { return NICE_STEPS[i] * mag; }
}
return 10 * mag;
}
function niceMax(maxValue: number): number {
const s = niceStep(maxValue);
return Math.ceil(maxValue / s - 1e-9) * s;
}
function maxOf(a: number[]): number {
let m = 0;
for (let i = 0; i < a.length; i += 1) { m = Math.max(m, a[i]); }
return m;
}
type Axis = { max: number; step: number };
let fromAxis: Axis = { max: niceMax(maxOf(goalVals)), step: niceStep(maxOf(goalVals)) };
let toAxis: Axis = fromAxis;
let rescaleMs = RESCALE_MS;
function shownMax(): number {
const e = smooth(rescaleMs / RESCALE_MS);
return lerp(fromAxis.max, toAxis.max, e);
}
// 목표 값이 바뀌면 축을 다시 고르고, 최댓값이 달라졌을 때만 RESCALE_MS 짜리 보간을 시작한다
function setGoal(lv: number): void {
level = lv;
for (let i = 0; i < BAR_N; i += 1) { goalVals[i] = Math.round(baseVals[i] * GROWTH[level] * (1 + 0.18 * ((i * 7 + level * 3) % 5) / 4)); }
const m = maxOf(goalVals);
const next: Axis = { max: niceMax(m), step: niceStep(m) };
if (next.max !== toAxis.max) {
fromAxis = { max: shownMax(), step: toAxis.step };
toAxis = next;
rescaleMs = 0;
}
}
function step(): void {
rescaleMs = Math.min(RESCALE_MS, rescaleMs + FIXED_DT);
for (let i = 0; i < BAR_N; i += 1) { vals[i] += (goalVals[i] - vals[i]) * VALUE_LERP; }
}
function settle(): void {
rescaleMs = RESCALE_MS;
fromAxis = toAxis;
for (let i = 0; i < BAR_N; i += 1) { vals[i] = goalVals[i]; }
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(LABELS[f] + ' · ' + groupDigits(goalVals[f])); } else { say(TXT_IDLE); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 700, 1200, 1700], function (k: number): void {
setGoal(k);
report();
});
}
// 한 벌의 눈금 — 간격 step 으로 0 부터 axisMax 까지, 지금 보이는 최댓값 sm 기준 높이에 그린다
function ticks(ax: Axis, sm: number, a: number): void {
if (a < 0.02) { return; }
for (let v = 0; v <= ax.max + 1e-9; v += ax.step) {
const y = PLOT_B - v / sm * (PLOT_B - PLOT_T);
if (y < PLOT_T - 8) { break; }
ctx.fillStyle = rgba(PAPER, (v === 0 ? 0.32 : 0.1) * a);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(groupDigits(v), PLOT_L - 8, y, 'right', 0.6 * a);
}
}
function draw(): void {
clearStage();
const sm = shownMax();
const e = smooth(rescaleMs / RESCALE_MS);
ticks(fromAxis, sm, 1 - e);
ticks(toAxis, sm, e);
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const h = vals[i] / sm * (PLOT_B - PLOT_T);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(PAL[i], dim ? 0.5 : 1);
barPath(ctx, x, PLOT_B, barW, h, 6);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(groupDigits(vals[i]), x + barW / 2, PLOT_B - h - 4);
axisText(LABELS[i], x + barW / 2, PLOT_B + 14, 'center', i === f ? 0.95 : 0.55);
}
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.7);
ctx.fillText(TXT_STEP + groupDigits(toAxis.step) + ' · max ' + groupDigits(toAxis.max), PLOT_R, PLOT_T - 16);
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function hitBar(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 24) { return -1; }
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (growBtn) { growBtn.addEventListener('click', function (): void { wake(); setGoal(Math.min(level + 1, GROWTH.length - 1)); after(); }); }
if (resetBtn) { resetBtn.addEventListener('click', function (): void { wake(); setGoal(0); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="값이 커지면 1·2·5 단위로 눈금을 다시 골라 축과 막대가 함께 줄어드는 막대 그래프. ←/→ 로 막대를 고르면 이름과 값을 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 값을 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="grow" type="button">값 늘리기</button>
<button className={cx("bc__btn")} data-role="reset" type="button">처음 값</button>
</div>
</div>
);
}05 · 값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축 — 측정 누적면적 35.748% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 1·2·5 계단 — 대략의 간격을 10의 거듭제곱으로 정규화해 NICE_STEPS 가운데 그 이상인 첫 값으로 올린다
function niceStep(maxValue) {
const raw = maxValue / TICK_TARGET_COUNT;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
for (let i = 0; i < NICE_STEPS.length; i += 1) {
if (norm <= NICE_STEPS[i] + 1e-9) {
return NICE_STEPS[i] * mag;
}
}
return 10 * mag;
}
function niceMax(maxValue) {
const s = niceStep(maxValue);
return Math.ceil(maxValue / s - 1e-9) * s;
}
06막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프
막대 60개(BAR_COUNT)의 경계마다 포인터까지의 거리를 반경 80px 로 나눈 u 를 g(u) = (P+1)u ÷ (Pu+1) 로 밀어내는데, 반경 밖 경계는 그대로 두므로 판 전체 폭 432px 는 바뀌지 않는다. 원래 7.2px 이던 막대가 넓어지는 배율은 곡선의 시작 기울기 P+1 = 4 를 넘지 못하고, 초점이 판 가운데일 때 8개가 넓어지며(가장 넓은 막대 22.7px, 약 3.2배) 16개가 반경 가장자리에서 눌린다. 일 단위 기록이 빽빽한 연간 그래프에서 한 구간만 들여다볼 때 쓰며, 초점이 2초에 한 번 판을 왕복하는 시연 동안 23장 비교 내내 움직였다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="막대 60개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프. 포인터를 움직이거나 ←/→ 로 막대를 고르면 번호와 값을 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">포인터나 ←/→ 로 막대를 넓혀 보기</p>
<div class="bc__tools">
<button class="bc__btn" data-role="lens" type="button" aria-pressed="false">렌즈 끄기</button>
<button class="bc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 막대 수 — 판 폭을 이 수로 똑같이 나눈 것이 원래 막대 폭
const BAR_COUNT = 60;
// 어안이 미치는 반경(px) — 포인터에서 이 거리 밖의 막대는 원래 자리 그대로
const FISHEYE_RADIUS_PX = 80;
// 어안 세기 — g(u) = (P + 1)u / (Pu + 1). 0 이면 그대로, 클수록 가운데가 넓어지고 반경 가장자리가 눌린다
const FISHEYE_POWER = 3;
const SEED = 4706;
const PLOT_L = 24;
const PLOT_R = 456;
const PLOT_T = 70;
const PLOT_B = 262;
const LENS_LERP = 0.14;
const FOCUS_LERP = 0.2;
const DEMO_LOOP = 2000;
const TXT_IDLE = '포인터나 ←/→ 로 막대를 넓혀 보기';
const lensBtn = root.querySelector('[data-role="lens"]');
const dataBtn = root.querySelector('[data-role="data"]');
const barW0 = (PLOT_R - PLOT_L) / BAR_COUNT;
let seed = SEED;
let vals = [];
// 하루하루 이어지는 가상 기록 — 앞 값에 시드 난수 걸음을 더해 매끈하게 오르내린다
function makeSeries(sd) {
const rnd = makeRng(sd);
const out = [];
let v = 50;
for (let i = 0; i < BAR_COUNT; i += 1) {
v = clamp(v + (rnd() - 0.5) * 22, 12, 98);
out.push(Math.round(v));
}
return out;
}
vals = makeSeries(seed);
let focusX = (PLOT_L + PLOT_R) / 2;
let focusGoal = focusX;
let lens = 0;
let lensGoal = 0;
let lensOff = false;
// 어안 함수 — 포인터에서 x 까지 거리를 반경으로 나눈 u 를 g(u) 로 밀어낸다. 반경 밖은 그대로라 바깥 자리가 이어진다
function fisheye(x, fx, power) {
const d = x - fx;
const ad = Math.abs(d);
if (ad >= FISHEYE_RADIUS_PX || power <= 0) {
return x;
}
const u = ad / FISHEYE_RADIUS_PX;
const g = ((power + 1) * u) / (power * u + 1);
return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
}
function edge(i) { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }
function step() {
focusX += (focusGoal - focusX) * FOCUS_LERP;
lens += ((lensOff ? 0 : lensGoal) - lens) * LENS_LERP;
}
function settle() {
focusX = focusGoal;
lens = lensOff ? 0 : lensGoal;
}
function report() {
const f = focusIdx();
if (f >= 0) {
say('#' + (f + 1) + ' · ' + groupDigits(vals[f]) + ' · ' + (edge(f + 1) - edge(f)).toFixed(1) + 'px');
}
else {
say(TXT_IDLE);
}
}
// 시연 — 렌즈가 판을 왼쪽에서 오른쪽으로 한 번 훑고 돌아온다
function demoTick(t) {
const p = (t % DEMO_LOOP) / DEMO_LOOP;
focusGoal = PLOT_L + 40 + (PLOT_R - PLOT_L - 80) * (0.5 - 0.5 * Math.cos(p * TAU));
lensGoal = 1;
}
function aimAt(i) {
focusGoal = PLOT_L + (i + 0.5) * barW0;
lensGoal = 1;
}
function draw() {
clearStage();
for (let g = 0; g <= 100; g += 25) {
const y = PLOT_B - g / 100 * (PLOT_B - PLOT_T);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.3 : 0.07);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
}
// 렌즈 자리 — 반경 안쪽 바닥을 옅게 밝혀 어디까지 넓어지는지 보여 준다
if (lens > 0.02) {
const gr = ctx.createLinearGradient(focusX - FISHEYE_RADIUS_PX, 0, focusX + FISHEYE_RADIUS_PX, 0);
gr.addColorStop(0, rgba(PANEL, 0));
gr.addColorStop(0.5, rgba(PANEL, lens));
gr.addColorStop(1, rgba(PANEL, 0));
ctx.fillStyle = gr;
ctx.fillRect(focusX - FISHEYE_RADIUS_PX, PLOT_T - 10, FISHEYE_RADIUS_PX * 2, PLOT_B - PLOT_T + 10);
}
const f = focusIdx();
for (let i = 0; i < BAR_COUNT; i += 1) {
const x0 = edge(i);
const x1 = edge(i + 1);
const w = x1 - x0;
// 넓어진 배율만큼 밝아진다 — 폭과 밝기가 같은 어안 값에서 나온다
const zoom = clamp((w / barW0 - 1) / 2.5, 0, 1);
const gap = w > 4 ? 1 : 0.4;
const h = vals[i] / 100 * (PLOT_B - PLOT_T);
const c = PAL[Math.floor(i / 10) % PAL.length];
ctx.fillStyle = rgba(mix(c, PAPER, 0.35 * zoom), i === f ? 1 : 0.55 + 0.45 * zoom);
barPath(ctx, x0 + gap / 2, PLOT_B, Math.max(0.5, w - gap), h, Math.min(3, w / 3));
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
// 막대가 글자보다 넓어지면 번호와 값을 붙인다
if (w >= 19) {
ctx.font = '700 10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(String(vals[i]), (x0 + x1) / 2, PLOT_B - h - 3);
axisText('#' + (i + 1), (x0 + x1) / 2, PLOT_B + 12, 'center', 0.8);
}
}
}
function after() {
if (still) {
settle();
}
report();
draw();
if (lensBtn) {
lensBtn.setAttribute('aria-pressed', lensOff ? 'true' : 'false');
}
}
// 포인터 자리의 막대 — 지금 휘어진 경계로 찾는다
function hitBar(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 20) {
return -1;
}
for (let i = 0; i < BAR_COUNT; i += 1) {
if (x >= edge(i) && x < edge(i + 1)) {
return i;
}
}
return -1;
}
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
focusGoal = clamp(p[0], PLOT_L, PLOT_R);
lensGoal = 1;
if (still) {
settle();
draw();
}
});
cv.addEventListener('pointerleave', function () {
if (demo || pinned >= 0) {
return;
}
lensGoal = 0;
sel = -1;
after();
});
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, BAR_COUNT); aimAt(sel); }, function () {
if (focusIdx() < 0) {
lensGoal = 0;
}
else {
aimAt(focusIdx());
}
after();
});
bindPointer(hitBar, after);
if (lensBtn) {
lensBtn.addEventListener('click', function () { wake(); lensOff = !lensOff; after(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); seed += 1; vals = makeSeries(seed); after(); });
}
if (still) {
lensGoal = 1;
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarPointerFisheyeZoom - vanilla 판(06-bar-chart-pointer-fisheye-zoom) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarPointerFisheyeZoom.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarPointerFisheyeZoom() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 막대 수 — 판 폭을 이 수로 똑같이 나눈 것이 원래 막대 폭
const BAR_COUNT = 60;
// 어안이 미치는 반경(px) — 포인터에서 이 거리 밖의 막대는 원래 자리 그대로
const FISHEYE_RADIUS_PX = 80;
// 어안 세기 — g(u) = (P + 1)u / (Pu + 1). 0 이면 그대로, 클수록 가운데가 넓어지고 반경 가장자리가 눌린다
const FISHEYE_POWER = 3;
const SEED = 4706;
const PLOT_L = 24;
const PLOT_R = 456;
const PLOT_T = 70;
const PLOT_B = 262;
const LENS_LERP = 0.14;
const FOCUS_LERP = 0.2;
const DEMO_LOOP = 2000;
const TXT_IDLE = '포인터나 ←/→ 로 막대를 넓혀 보기';
const lensBtn = root.querySelector('[data-role="lens"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
const barW0 = (PLOT_R - PLOT_L) / BAR_COUNT;
let seed = SEED;
let vals: number[] = [];
// 하루하루 이어지는 가상 기록 — 앞 값에 시드 난수 걸음을 더해 매끈하게 오르내린다
function makeSeries(sd: number): number[] {
const rnd = makeRng(sd);
const out: number[] = [];
let v = 50;
for (let i = 0; i < BAR_COUNT; i += 1) {
v = clamp(v + (rnd() - 0.5) * 22, 12, 98);
out.push(Math.round(v));
}
return out;
}
vals = makeSeries(seed);
let focusX = (PLOT_L + PLOT_R) / 2;
let focusGoal = focusX;
let lens = 0;
let lensGoal = 0;
let lensOff = false;
// 어안 함수 — 포인터에서 x 까지 거리를 반경으로 나눈 u 를 g(u) 로 밀어낸다. 반경 밖은 그대로라 바깥 자리가 이어진다
function fisheye(x: number, fx: number, power: number): number {
const d = x - fx;
const ad = Math.abs(d);
if (ad >= FISHEYE_RADIUS_PX || power <= 0) { return x; }
const u = ad / FISHEYE_RADIUS_PX;
const g = ((power + 1) * u) / (power * u + 1);
return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
}
function edge(i: number): number { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }
function step(): void {
focusX += (focusGoal - focusX) * FOCUS_LERP;
lens += ((lensOff ? 0 : lensGoal) - lens) * LENS_LERP;
}
function settle(): void {
focusX = focusGoal;
lens = lensOff ? 0 : lensGoal;
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say('#' + (f + 1) + ' · ' + groupDigits(vals[f]) + ' · ' + (edge(f + 1) - edge(f)).toFixed(1) + 'px'); } else { say(TXT_IDLE); }
}
// 시연 — 렌즈가 판을 왼쪽에서 오른쪽으로 한 번 훑고 돌아온다
function demoTick(t: number): void {
const p = (t % DEMO_LOOP) / DEMO_LOOP;
focusGoal = PLOT_L + 40 + (PLOT_R - PLOT_L - 80) * (0.5 - 0.5 * Math.cos(p * TAU));
lensGoal = 1;
}
function aimAt(i: number): void {
focusGoal = PLOT_L + (i + 0.5) * barW0;
lensGoal = 1;
}
function draw(): void {
clearStage();
for (let g = 0; g <= 100; g += 25) {
const y = PLOT_B - g / 100 * (PLOT_B - PLOT_T);
ctx.fillStyle = rgba(PAPER, g === 0 ? 0.3 : 0.07);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
}
// 렌즈 자리 — 반경 안쪽 바닥을 옅게 밝혀 어디까지 넓어지는지 보여 준다
if (lens > 0.02) {
const gr = ctx.createLinearGradient(focusX - FISHEYE_RADIUS_PX, 0, focusX + FISHEYE_RADIUS_PX, 0);
gr.addColorStop(0, rgba(PANEL, 0));
gr.addColorStop(0.5, rgba(PANEL, lens));
gr.addColorStop(1, rgba(PANEL, 0));
ctx.fillStyle = gr;
ctx.fillRect(focusX - FISHEYE_RADIUS_PX, PLOT_T - 10, FISHEYE_RADIUS_PX * 2, PLOT_B - PLOT_T + 10);
}
const f = focusIdx();
for (let i = 0; i < BAR_COUNT; i += 1) {
const x0 = edge(i);
const x1 = edge(i + 1);
const w = x1 - x0;
// 넓어진 배율만큼 밝아진다 — 폭과 밝기가 같은 어안 값에서 나온다
const zoom = clamp((w / barW0 - 1) / 2.5, 0, 1);
const gap = w > 4 ? 1 : 0.4;
const h = vals[i] / 100 * (PLOT_B - PLOT_T);
const c = PAL[Math.floor(i / 10) % PAL.length];
ctx.fillStyle = rgba(mix(c, PAPER, 0.35 * zoom), i === f ? 1 : 0.55 + 0.45 * zoom);
barPath(ctx, x0 + gap / 2, PLOT_B, Math.max(0.5, w - gap), h, Math.min(3, w / 3));
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
// 막대가 글자보다 넓어지면 번호와 값을 붙인다
if (w >= 19) {
ctx.font = '700 10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(String(vals[i]), (x0 + x1) / 2, PLOT_B - h - 3);
axisText('#' + (i + 1), (x0 + x1) / 2, PLOT_B + 12, 'center', 0.8);
}
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (lensBtn) { lensBtn.setAttribute('aria-pressed', lensOff ? 'true' : 'false'); }
}
// 포인터 자리의 막대 — 지금 휘어진 경계로 찾는다
function hitBar(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 20) { return -1; }
for (let i = 0; i < BAR_COUNT; i += 1) { if (x >= edge(i) && x < edge(i + 1)) { return i; } }
return -1;
}
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
focusGoal = clamp(p[0], PLOT_L, PLOT_R);
lensGoal = 1;
if (still) { settle(); draw(); }
});
cv.addEventListener('pointerleave', function (): void {
if (demo || pinned >= 0) { return; }
lensGoal = 0;
sel = -1;
after();
});
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, BAR_COUNT); aimAt(sel); }, function (): void {
if (focusIdx() < 0) { lensGoal = 0; } else { aimAt(focusIdx()); }
after();
});
bindPointer(hitBar, after);
if (lensBtn) { lensBtn.addEventListener('click', function (): void { wake(); lensOff = !lensOff; after(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); seed += 1; vals = makeSeries(seed); after(); }); }
if (still) { lensGoal = 1; }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="막대 60개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프. 포인터를 움직이거나 ←/→ 로 막대를 고르면 번호와 값을 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">포인터나 ←/→ 로 막대를 넓혀 보기</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="lens" type="button" aria-pressed="false">렌즈 끄기</button>
<button className={cx("bc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}06 · 막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프 — 측정 누적면적 40.077% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 어안 함수 — 포인터에서 x 까지 거리를 반경으로 나눈 u 를 g(u) 로 밀어낸다. 반경 밖은 그대로라 바깥 자리가 이어진다
function fisheye(x, fx, power) {
const d = x - fx;
const ad = Math.abs(d);
if (ad >= FISHEYE_RADIUS_PX || power <= 0) {
return x;
}
const u = ad / FISHEYE_RADIUS_PX;
const g = ((power + 1) * u) / (power * u + 1);
return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
}
function edge(i) { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }
07빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥
4×4 격자 칸마다 기둥을 30° 등축 투영으로 세워 윗면과 두 옆면을 구하고, 면 법선과 방위 LIGHT_AZIMUTH_DEG·고도 42° 의 빛 벡터를 내적한 값만큼 바탕 빛 0.3 에 나머지 0.7 을 얹어 면마다 밝기를 정한 뒤 gx + gy 가 작은 뒤 칸부터 칠한다. 시작 방위 135° 에서 +x 옆면은 빛을 등져 바탕 빛 0.3 만 받고 +y 옆면은 0.668, 윗면은 방위와 상관없이 0.768 이며, 바닥 그림자는 기둥 높이 1px 당 1.111px 길이로 빛 반대쪽에 눕는다. 지역별 수치를 입체로 보여 주는 첫 화면용이고, 빛이 2초에 한 바퀴 돌아 화면 넓이는 16.8% 만 바뀌어도 23/23 장이 움직였다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="빛 방향에 따라 윗면과 옆면 밝기가 달라지는 등축 3D 기둥 그래프. 포인터로 빛 방향을 옮기고 ←/→ 로 기둥을 고르면 이름과 값을 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">←/→ 로 기둥 고르기</p>
<div class="bc__tools">
<button class="bc__btn" data-role="spin" type="button" aria-pressed="false">빛 멈춤</button>
<button class="bc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 등축 투영 각 — 바닥의 x·y 축이 화면 가로에서 이 각만큼 기운다 (cos 는 가로, sin 은 세로 몫)
const ISO_ANGLE_DEG = 30;
// 빛이 오는 방향 — 바닥 평면에서 +x 축을 0° 로 잰 방위각. 시연·포인터가 이 값에서 출발해 돌린다
const LIGHT_AZIMUTH_DEG = 135;
// 기둥 바닥의 한 변(px) — 칸 CELL 보다 작아 기둥 사이에 길이 생긴다
const DEPTH_PX = 24;
const LIGHT_ELEV_DEG = 42;
const AMBIENT = 0.3;
const GRID = 4;
const CELL = 34;
const SEED = 4707;
const OX = 240;
const OY = 134;
const HEIGHT_LERP = 0.12;
const SPIN_DEG_PER_S = 180;
const DEMO_LOOP = 2000;
const ROWS = ['A', 'B', 'C', 'D'];
const TXT_IDLE = '←/→ 로 기둥 고르기';
const spinBtn = root.querySelector('[data-role="spin"]');
const dataBtn = root.querySelector('[data-role="data"]');
const DEG = Math.PI / 180;
const cosA = Math.cos(ISO_ANGLE_DEG * DEG);
const sinA = Math.sin(ISO_ANGLE_DEG * DEG);
const N = GRID * GRID;
let seed = SEED;
let goal = seededValues(seed, N, 18, 100);
const hgt = goal.map(function () { return 0; });
let azimuth = LIGHT_AZIMUTH_DEG;
let spinning = true;
let aiming = false;
// 등축 투영 — 바닥 (wx, wy) 와 높이 wz 를 화면 점으로
function project(wx, wy, wz) {
return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
}
// 빛 벡터 — 방위각과 고도에서 단위 벡터 (Lx, Ly, Lz)
function lightVec() {
const az = azimuth * DEG;
const el = LIGHT_ELEV_DEG * DEG;
return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
}
// 면 밝기 — 바탕 빛 AMBIENT 에 법선·빛 내적(음수면 0)을 더한다
function shade(nx, ny, nz, L) {
return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
}
function tone(c, b) { return [clamp(c[0] * b, 0, 255), clamp(c[1] * b, 0, 255), clamp(c[2] * b, 0, 255)]; }
function poly(pts) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.closePath();
}
// 볼록 껍질 — 바닥 네 점과 그림자로 옮긴 네 점을 한 덩어리 그림자로 감싼다
function hull(ps) {
const p = ps.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
const cross = function (o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); };
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
function cellOf(i) {
const gx = i % GRID;
const gy = Math.floor(i / GRID);
const x0 = gx * CELL + (CELL - DEPTH_PX) / 2;
const y0 = gy * CELL + (CELL - DEPTH_PX) / 2;
return [x0, y0, x0 + DEPTH_PX, y0 + DEPTH_PX];
}
function step() {
for (let i = 0; i < N; i += 1) {
hgt[i] += (goal[i] - hgt[i]) * HEIGHT_LERP;
}
if (spinning && !aiming) {
azimuth = (azimuth + SPIN_DEG_PER_S * FIXED_DT / 1000) % 360;
}
}
function settle() {
for (let i = 0; i < N; i += 1) {
hgt[i] = goal[i];
}
}
function nameOf(i) { return ROWS[Math.floor(i / GRID)] + String((i % GRID) + 1); }
function report() {
const f = focusIdx();
if (f >= 0) {
say(nameOf(f) + ' · ' + groupDigits(goal[f]));
}
else {
say(TXT_IDLE);
}
}
function demoTick(t) {
// 시연 — 빛이 한 바퀴 반을 돌고, 루프 처음에 기둥이 바닥에서 솟는다
runSchedule(t, DEMO_LOOP, [200], function () {
for (let i = 0; i < N; i += 1) {
hgt[i] = 0;
}
});
}
function draw() {
clearStage();
const L = lightVec();
const ext = GRID * CELL;
// 바닥 판
ctx.fillStyle = rgba(PANEL, 1);
poly([project(0, 0, 0), project(ext, 0, 0), project(ext, ext, 0), project(0, ext, 0)]);
ctx.fill();
// 그림자 — 높이 h 인 꼭대기를 빛 반대쪽으로 h·(Lx, Ly)/Lz 만큼 바닥에 눕힌다
ctx.fillStyle = 'rgba(6,6,14,0.38)';
for (let i = 0; i < N; i += 1) {
const c = cellOf(i);
const h = hgt[i];
const sx = -h * L[0] / L[2];
const sy = -h * L[1] / L[2];
const base = [[c[0], c[1]], [c[2], c[1]], [c[2], c[3]], [c[0], c[3]]];
const pts = [];
for (let k = 0; k < 4; k += 1) {
pts.push(base[k]);
pts.push([base[k][0] + sx, base[k][1] + sy]);
}
poly(hull(pts).map(function (q) { return project(clamp(q[0], 0, ext), clamp(q[1], 0, ext), 0); }));
ctx.fill();
}
// 뒤에서 앞으로 — gx + gy 가 작은 칸(화면 위쪽)부터 그려 앞 기둥이 뒤 기둥을 가린다
const order = [];
for (let i = 0; i < N; i += 1) {
order.push(i);
}
order.sort(function (a, b) { return (a % GRID + Math.floor(a / GRID)) - (b % GRID + Math.floor(b / GRID)); });
const f = focusIdx();
const bTop = shade(0, 0, 1, L);
const bRight = shade(1, 0, 0, L);
const bLeft = shade(0, 1, 0, L);
for (let n = 0; n < N; n += 1) {
const i = order[n];
const c = cellOf(i);
const z = hgt[i] + (i === f ? 6 : 0);
const col = PAL[(i % GRID + Math.floor(i / GRID)) % PAL.length];
const dim = f >= 0 && f !== i ? 0.6 : 1;
// +y 쪽 면(화면 왼쪽 아래)
ctx.fillStyle = rgba(tone(col, bLeft * 1.1), dim);
poly([project(c[0], c[3], 0), project(c[2], c[3], 0), project(c[2], c[3], z), project(c[0], c[3], z)]);
ctx.fill();
// +x 쪽 면(화면 오른쪽 아래)
ctx.fillStyle = rgba(tone(col, bRight * 1.1), dim);
poly([project(c[2], c[1], 0), project(c[2], c[3], 0), project(c[2], c[3], z), project(c[2], c[1], z)]);
ctx.fill();
// 윗면
ctx.fillStyle = rgba(tone(col, bTop * 1.1), dim);
poly([project(c[0], c[1], z), project(c[2], c[1], z), project(c[2], c[3], z), project(c[0], c[3], z)]);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
const tp = project((c[0] + c[2]) / 2, (c[1] + c[3]) / 2, z);
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(nameOf(i) + ' ' + groupDigits(goal[i]), tp[0], tp[1] - 6);
}
}
// 빛 나침반 — 오른쪽 아래, 바닥 평면에서 빛이 오는 쪽을 가리킨다
const cx = 428;
const cy = 252;
const tip = project(Math.cos(azimuth * DEG) * 22, Math.sin(azimuth * DEG) * 22, 0);
const org = project(0, 0, 0);
ctx.strokeStyle = rgba(PAPER, 0.25);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(cx, cy, 22 * cosA * 1.41, 22 * sinA * 1.41, 0, 0, TAU);
ctx.stroke();
ctx.fillStyle = rgba(PAL[5], 1);
ctx.beginPath();
ctx.arc(cx + tip[0] - org[0], cy + tip[1] - org[1], 4, 0, TAU);
ctx.fill();
axisText(Math.round(azimuth) + '°', cx, cy, 'center', 0.7);
}
function after() {
if (still) {
settle();
}
report();
draw();
if (spinBtn) {
spinBtn.setAttribute('aria-pressed', spinning ? 'false' : 'true');
}
}
function inside(pt, pts) {
let hit = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
const a = pts[i];
const b = pts[j];
if ((a[1] > pt[1]) !== (b[1] > pt[1]) && pt[0] < (b[0] - a[0]) * (pt[1] - a[1]) / (b[1] - a[1]) + a[0]) {
hit = !hit;
}
}
return hit;
}
// 포인터 자리의 기둥 — 앞 칸부터 윗면·옆면 다각형 안인지 본다
function hitColumn(x, y) {
for (let s = (GRID - 1) * 2; s >= 0; s -= 1) {
for (let i = 0; i < N; i += 1) {
if (i % GRID + Math.floor(i / GRID) !== s) {
continue;
}
const c = cellOf(i);
const shape = [project(c[0], c[1], hgt[i]), project(c[2], c[1], hgt[i]), project(c[2], c[1], 0), project(c[2], c[3], 0), project(c[0], c[3], 0), project(c[0], c[3], hgt[i])];
if (inside([x, y], shape)) {
return i;
}
}
}
return -1;
}
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
// 포인터가 판 가운데에서 어느 쪽에 있는지로 빛 방위각을 정한다 (화면 → 바닥 좌표로 되돌려 atan2)
const p = toScene(ev);
const dx = (p[0] - OX) / cosA;
const dy = (p[1] - (OY + GRID * CELL * sinA)) / sinA;
const wx = (dx + dy) / 2;
const wy = (dy - dx) / 2;
azimuth = (Math.atan2(wy, wx) / DEG + 360) % 360;
aiming = true;
if (still) {
draw();
}
});
cv.addEventListener('pointerleave', function () { aiming = false; });
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, N); }, after);
bindPointer(hitColumn, after);
if (spinBtn) {
spinBtn.addEventListener('click', function () { wake(); spinning = !spinning; if (still) {
azimuth = (azimuth + 45) % 360;
} after(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); seed += 1; goal = seededValues(seed, N, 18, 100); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarIsometricColumnShade - vanilla 판(07-bar-chart-isometric-column-shade) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarIsometricColumnShade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarIsometricColumnShade() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 등축 투영 각 — 바닥의 x·y 축이 화면 가로에서 이 각만큼 기운다 (cos 는 가로, sin 은 세로 몫)
const ISO_ANGLE_DEG = 30;
// 빛이 오는 방향 — 바닥 평면에서 +x 축을 0° 로 잰 방위각. 시연·포인터가 이 값에서 출발해 돌린다
const LIGHT_AZIMUTH_DEG = 135;
// 기둥 바닥의 한 변(px) — 칸 CELL 보다 작아 기둥 사이에 길이 생긴다
const DEPTH_PX = 24;
const LIGHT_ELEV_DEG = 42;
const AMBIENT = 0.3;
const GRID = 4;
const CELL = 34;
const SEED = 4707;
const OX = 240;
const OY = 134;
const HEIGHT_LERP = 0.12;
const SPIN_DEG_PER_S = 180;
const DEMO_LOOP = 2000;
const ROWS = ['A', 'B', 'C', 'D'];
const TXT_IDLE = '←/→ 로 기둥 고르기';
const spinBtn = root.querySelector('[data-role="spin"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
const DEG = Math.PI / 180;
const cosA = Math.cos(ISO_ANGLE_DEG * DEG);
const sinA = Math.sin(ISO_ANGLE_DEG * DEG);
const N = GRID * GRID;
let seed = SEED;
let goal: number[] = seededValues(seed, N, 18, 100);
const hgt: number[] = goal.map(function (): number { return 0; });
let azimuth = LIGHT_AZIMUTH_DEG;
let spinning = true;
let aiming = false;
type P2 = [number, number];
// 등축 투영 — 바닥 (wx, wy) 와 높이 wz 를 화면 점으로
function project(wx: number, wy: number, wz: number): P2 {
return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
}
// 빛 벡터 — 방위각과 고도에서 단위 벡터 (Lx, Ly, Lz)
function lightVec(): [number, number, number] {
const az = azimuth * DEG;
const el = LIGHT_ELEV_DEG * DEG;
return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
}
// 면 밝기 — 바탕 빛 AMBIENT 에 법선·빛 내적(음수면 0)을 더한다
function shade(nx: number, ny: number, nz: number, L: [number, number, number]): number {
return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
}
function tone(c: RGB, b: number): RGB { return [clamp(c[0] * b, 0, 255), clamp(c[1] * b, 0, 255), clamp(c[2] * b, 0, 255)]; }
function poly(pts: P2[]): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) { ctx.lineTo(pts[i][0], pts[i][1]); }
ctx.closePath();
}
// 볼록 껍질 — 바닥 네 점과 그림자로 옮긴 네 점을 한 덩어리 그림자로 감싼다
function hull(ps: P2[]): P2[] {
const p = ps.slice().sort(function (a: P2, b: P2): number { return a[0] - b[0] || a[1] - b[1]; });
const cross = function (o: P2, a: P2, b: P2): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); };
const lo: P2[] = [];
for (let i = 0; i < p.length; i += 1) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); } lo.push(p[i]); }
const up: P2[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) { while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); } up.push(p[i]); }
lo.pop();
up.pop();
return lo.concat(up);
}
function cellOf(i: number): [number, number, number, number] {
const gx = i % GRID;
const gy = Math.floor(i / GRID);
const x0 = gx * CELL + (CELL - DEPTH_PX) / 2;
const y0 = gy * CELL + (CELL - DEPTH_PX) / 2;
return [x0, y0, x0 + DEPTH_PX, y0 + DEPTH_PX];
}
function step(): void {
for (let i = 0; i < N; i += 1) { hgt[i] += (goal[i] - hgt[i]) * HEIGHT_LERP; }
if (spinning && !aiming) { azimuth = (azimuth + SPIN_DEG_PER_S * FIXED_DT / 1000) % 360; }
}
function settle(): void {
for (let i = 0; i < N; i += 1) { hgt[i] = goal[i]; }
}
function nameOf(i: number): string { return ROWS[Math.floor(i / GRID)] + String((i % GRID) + 1); }
function report(): void {
const f = focusIdx();
if (f >= 0) { say(nameOf(f) + ' · ' + groupDigits(goal[f])); } else { say(TXT_IDLE); }
}
function demoTick(t: number): void {
// 시연 — 빛이 한 바퀴 반을 돌고, 루프 처음에 기둥이 바닥에서 솟는다
runSchedule(t, DEMO_LOOP, [200], function (): void {
for (let i = 0; i < N; i += 1) { hgt[i] = 0; }
});
}
function draw(): void {
clearStage();
const L = lightVec();
const ext = GRID * CELL;
// 바닥 판
ctx.fillStyle = rgba(PANEL, 1);
poly([project(0, 0, 0), project(ext, 0, 0), project(ext, ext, 0), project(0, ext, 0)]);
ctx.fill();
// 그림자 — 높이 h 인 꼭대기를 빛 반대쪽으로 h·(Lx, Ly)/Lz 만큼 바닥에 눕힌다
ctx.fillStyle = 'rgba(6,6,14,0.38)';
for (let i = 0; i < N; i += 1) {
const c = cellOf(i);
const h = hgt[i];
const sx = -h * L[0] / L[2];
const sy = -h * L[1] / L[2];
const base: P2[] = [[c[0], c[1]], [c[2], c[1]], [c[2], c[3]], [c[0], c[3]]];
const pts: P2[] = [];
for (let k = 0; k < 4; k += 1) { pts.push(base[k]); pts.push([base[k][0] + sx, base[k][1] + sy]); }
poly(hull(pts).map(function (q: P2): P2 { return project(clamp(q[0], 0, ext), clamp(q[1], 0, ext), 0); }));
ctx.fill();
}
// 뒤에서 앞으로 — gx + gy 가 작은 칸(화면 위쪽)부터 그려 앞 기둥이 뒤 기둥을 가린다
const order: number[] = [];
for (let i = 0; i < N; i += 1) { order.push(i); }
order.sort(function (a: number, b: number): number { return (a % GRID + Math.floor(a / GRID)) - (b % GRID + Math.floor(b / GRID)); });
const f = focusIdx();
const bTop = shade(0, 0, 1, L);
const bRight = shade(1, 0, 0, L);
const bLeft = shade(0, 1, 0, L);
for (let n = 0; n < N; n += 1) {
const i = order[n];
const c = cellOf(i);
const z = hgt[i] + (i === f ? 6 : 0);
const col = PAL[(i % GRID + Math.floor(i / GRID)) % PAL.length];
const dim = f >= 0 && f !== i ? 0.6 : 1;
// +y 쪽 면(화면 왼쪽 아래)
ctx.fillStyle = rgba(tone(col, bLeft * 1.1), dim);
poly([project(c[0], c[3], 0), project(c[2], c[3], 0), project(c[2], c[3], z), project(c[0], c[3], z)]);
ctx.fill();
// +x 쪽 면(화면 오른쪽 아래)
ctx.fillStyle = rgba(tone(col, bRight * 1.1), dim);
poly([project(c[2], c[1], 0), project(c[2], c[3], 0), project(c[2], c[3], z), project(c[2], c[1], z)]);
ctx.fill();
// 윗면
ctx.fillStyle = rgba(tone(col, bTop * 1.1), dim);
poly([project(c[0], c[1], z), project(c[2], c[1], z), project(c[2], c[3], z), project(c[0], c[3], z)]);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
const tp = project((c[0] + c[2]) / 2, (c[1] + c[3]) / 2, z);
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(nameOf(i) + ' ' + groupDigits(goal[i]), tp[0], tp[1] - 6);
}
}
// 빛 나침반 — 오른쪽 아래, 바닥 평면에서 빛이 오는 쪽을 가리킨다
const cx = 428;
const cy = 252;
const tip = project(Math.cos(azimuth * DEG) * 22, Math.sin(azimuth * DEG) * 22, 0);
const org = project(0, 0, 0);
ctx.strokeStyle = rgba(PAPER, 0.25);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(cx, cy, 22 * cosA * 1.41, 22 * sinA * 1.41, 0, 0, TAU);
ctx.stroke();
ctx.fillStyle = rgba(PAL[5], 1);
ctx.beginPath();
ctx.arc(cx + tip[0] - org[0], cy + tip[1] - org[1], 4, 0, TAU);
ctx.fill();
axisText(Math.round(azimuth) + '°', cx, cy, 'center', 0.7);
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (spinBtn) { spinBtn.setAttribute('aria-pressed', spinning ? 'false' : 'true'); }
}
function inside(pt: P2, pts: P2[]): boolean {
let hit = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
const a = pts[i];
const b = pts[j];
if ((a[1] > pt[1]) !== (b[1] > pt[1]) && pt[0] < (b[0] - a[0]) * (pt[1] - a[1]) / (b[1] - a[1]) + a[0]) { hit = !hit; }
}
return hit;
}
// 포인터 자리의 기둥 — 앞 칸부터 윗면·옆면 다각형 안인지 본다
function hitColumn(x: number, y: number): number {
for (let s = (GRID - 1) * 2; s >= 0; s -= 1) {
for (let i = 0; i < N; i += 1) {
if (i % GRID + Math.floor(i / GRID) !== s) { continue; }
const c = cellOf(i);
const shape: P2[] = [project(c[0], c[1], hgt[i]), project(c[2], c[1], hgt[i]), project(c[2], c[1], 0), project(c[2], c[3], 0), project(c[0], c[3], 0), project(c[0], c[3], hgt[i])];
if (inside([x, y], shape)) { return i; }
}
}
return -1;
}
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
// 포인터가 판 가운데에서 어느 쪽에 있는지로 빛 방위각을 정한다 (화면 → 바닥 좌표로 되돌려 atan2)
const p = toScene(ev);
const dx = (p[0] - OX) / cosA;
const dy = (p[1] - (OY + GRID * CELL * sinA)) / sinA;
const wx = (dx + dy) / 2;
const wy = (dy - dx) / 2;
azimuth = (Math.atan2(wy, wx) / DEG + 360) % 360;
aiming = true;
if (still) { draw(); }
});
cv.addEventListener('pointerleave', function (): void { aiming = false; });
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, N); }, after);
bindPointer(hitColumn, after);
if (spinBtn) { spinBtn.addEventListener('click', function (): void { wake(); spinning = !spinning; if (still) { azimuth = (azimuth + 45) % 360; } after(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); seed += 1; goal = seededValues(seed, N, 18, 100); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="빛 방향에 따라 윗면과 옆면 밝기가 달라지는 등축 3D 기둥 그래프. 포인터로 빛 방향을 옮기고 ←/→ 로 기둥을 고르면 이름과 값을 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">←/→ 로 기둥 고르기</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="spin" type="button" aria-pressed="false">빛 멈춤</button>
<button className={cx("bc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}07 · 빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥 — 측정 누적면적 16.805% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 등축 투영 — 바닥 (wx, wy) 와 높이 wz 를 화면 점으로
function project(wx, wy, wz) {
return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
}
// 빛 벡터 — 방위각과 고도에서 단위 벡터 (Lx, Ly, Lz)
function lightVec() {
const az = azimuth * DEG;
const el = LIGHT_ELEV_DEG * DEG;
return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
}
// 면 밝기 — 바탕 빛 AMBIENT 에 법선·빛 내적(음수면 0)을 더한다
function shade(nx, ny, nz, L) {
return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
}
08떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대
균등 난수 4개를 평균한 값으로 15칸 가운데 하나를 골라 점 360개를 위에서 떨어뜨리고, 중력 1,400px/s² 로 속도를 쌓다가 미리 예약해 둔 제 자리 높이에 닿으면 속도에 0.35 를 곱해 튕기고, 초속 70px 이하면 멈춰 쌓인다. 평균을 내면 가운데 칸 몫이 17.4% 로 두꺼워지고(「균등 난수 하나로」 를 누르면 칸마다 약 6.7%), 시드 실행에서는 가장 높은 칸이 360개 중 60개를 받았고, 234.5px 를 떨어지는 점 하나는 제 자리에 세 번 닿은 뒤 1,136ms 에 멈춘다. 응답 시간 분포를 설명하는 기술 블로그에 붙이기 좋은 판으로, 누적면적 24.9%·강도 105.2 가 나왔다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="떨어진 점들이 칸마다 튕겼다 멈춰 쌓이며 히스토그램 막대가 되는 그래프. ←/→ 로 구간을 고르면 쌓인 점 수를 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">구간을 고르면 쌓인 점 수를 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="replay" type="button">다시 떨어뜨리기</button>
<button class="bc__btn" data-role="flat" type="button" aria-pressed="false">균등 난수 하나로</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 중력 가속도(px/s²) — 16ms 걸음마다 속도에 더하고, 속도로 위치를 옮긴다
const DROP_GRAVITY = 1400;
// 칸 수 — 0~1 사이 표본을 이만큼 똑같이 나눠 어느 칸에 떨어질지 정한다
const BIN_COUNT = 15;
// 쌓인 점에 닿을 때 남기는 속도 비율 — 이 값을 곱해 위로 튕긴다
const BOUNCE_LOSS = 0.35;
// 균등 난수 몇 개를 평균 내는지 — 1 이면 칸마다 고르고, 늘수록 가운데가 두꺼워진다
const MEAN_OF = 4;
const DOT_N = 360;
const SPAWN_PER_STEP = 6;
const REST_SPEED = 70;
const DOT_COLS = 3;
const DOT_STEP = 7;
const DOT_R = 2.8;
const SEED = 4708;
const PLOT_L = 34;
const PLOT_R = 446;
const PLOT_T = 52;
const PLOT_B = 264;
const DEMO_LOOP = 2000;
const TXT_IDLE = '구간을 고르면 쌓인 점 수를 읽어 줌';
const TXT_BIN = '구간 ';
const replayBtn = root.querySelector('[data-role="replay"]');
const flatBtn = root.querySelector('[data-role="flat"]');
const binW = (PLOT_R - PLOT_L) / BIN_COUNT;
let dots = [];
let reserved = [];
let landed = [];
let rnd = makeRng(SEED);
let spawned = 0;
let flat = false;
let round = 0;
// 표본 하나 — 균등 난수 n 개의 평균. 여러 개를 더할수록 가운데(0.5) 근처가 잦아진다
function sampleMean(n) {
let s = 0;
for (let i = 0; i < n; i += 1) {
s += rnd();
}
return s / n;
}
function reset() {
round += 1;
rnd = makeRng(SEED + round);
dots = [];
reserved = [];
landed = [];
for (let b = 0; b < BIN_COUNT; b += 1) {
reserved.push(0);
landed.push(0);
}
spawned = 0;
}
// 점 하나 내보내기 — 칸을 고르고, 그 칸에서 몇 번째 자리에 앉을지 미리 잡아 둔다
function spawn() {
const u = sampleMean(flat ? 1 : MEAN_OF);
const bin = Math.min(BIN_COUNT - 1, Math.floor(u * BIN_COUNT));
const slot = reserved[bin];
reserved[bin] += 1;
const col = slot % DOT_COLS;
const row = Math.floor(slot / DOT_COLS);
const x = PLOT_L + bin * binW + (binW - (DOT_COLS - 1) * DOT_STEP) / 2 + col * DOT_STEP;
dots.push({ x: x, y: PLOT_T - 6 - rnd() * 40, v: 0, bin: bin, restY: PLOT_B - DOT_STEP / 2 - row * DOT_STEP, rest: false });
spawned += 1;
}
function step() {
const dt = FIXED_DT / 1000;
for (let k = 0; k < SPAWN_PER_STEP && spawned < DOT_N; k += 1) {
spawn();
}
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (d.rest) {
continue;
}
d.v += DROP_GRAVITY * dt;
d.y += d.v * dt;
// 제 자리(쌓인 높이)에 닿으면 튕기거나, 충분히 느리면 멈춰 쌓인다
if (d.y >= d.restY) {
d.y = d.restY;
if (d.v > REST_SPEED) {
d.v = -d.v * BOUNCE_LOSS;
}
else {
d.v = 0;
d.rest = true;
landed[d.bin] += 1;
}
}
}
}
function settle() {
while (spawned < DOT_N) {
spawn();
}
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (!d.rest) {
d.rest = true;
d.y = d.restY;
d.v = 0;
landed[d.bin] += 1;
}
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(TXT_BIN + (f + 1) + ' · ' + landed[f] + ' / ' + spawned);
}
else {
say(TXT_IDLE);
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200], function () { reset(); });
}
function draw() {
clearStage();
ctx.fillStyle = rgba(PAPER, 0.3);
ctx.fillRect(PLOT_L, PLOT_B, PLOT_R - PLOT_L, 1);
const f = focusIdx();
// 막대 — 칸마다 멈춘 점 줄 수만큼의 높이. 쌓인 점 기둥을 그대로 감싼다
for (let b = 0; b < BIN_COUNT; b += 1) {
const rows = Math.ceil(landed[b] / DOT_COLS);
const h = rows * DOT_STEP;
const x = PLOT_L + b * binW + 2;
const c = PAL[b % 3 === 0 ? 0 : (b % 3 === 1 ? 3 : 6)];
ctx.fillStyle = rgba(c, b === f ? 0.42 : 0.2);
barPath(ctx, x, PLOT_B, binW - 4, h + (h > 0 ? 4 : 0), 5);
ctx.fill();
if (b === f) {
ringStyle(b, 1);
ctx.stroke();
ctx.setLineDash([]);
}
if (landed[b] > 0) {
axisText(String(landed[b]), x + (binW - 4) / 2, PLOT_B - h - 12, 'center', b === f ? 1 : 0.7);
}
axisText(String(b + 1), x + (binW - 4) / 2, PLOT_B + 12, 'center', b === f ? 0.95 : 0.45);
}
// 점 — 떨어지는 동안은 밝게, 멈추면 칸 색으로
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
const c = PAL[d.bin % 3 === 0 ? 0 : (d.bin % 3 === 1 ? 3 : 6)];
ctx.fillStyle = d.rest ? rgba(c, 1) : rgba(mix(c, PAPER, 0.55), 1);
ctx.beginPath();
ctx.arc(d.x, d.y, DOT_R, 0, TAU);
ctx.fill();
}
}
function after() {
if (still) {
settle();
}
report();
draw();
if (flatBtn) {
flatBtn.setAttribute('aria-pressed', flat ? 'true' : 'false');
}
}
function hitBin(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 20) {
return -1;
}
const b = Math.floor((x - PLOT_L) / binW);
return b >= 0 && b < BIN_COUNT ? b : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, BIN_COUNT); }, after);
bindPointer(hitBin, after);
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); reset(); after(); });
}
if (flatBtn) {
flatBtn.addEventListener('click', function () { wake(); flat = !flat; reset(); after(); });
}
reset();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarHistogramParticleDrop - vanilla 판(08-bar-chart-histogram-particle-drop) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarHistogramParticleDrop.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarHistogramParticleDrop() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 중력 가속도(px/s²) — 16ms 걸음마다 속도에 더하고, 속도로 위치를 옮긴다
const DROP_GRAVITY = 1400;
// 칸 수 — 0~1 사이 표본을 이만큼 똑같이 나눠 어느 칸에 떨어질지 정한다
const BIN_COUNT = 15;
// 쌓인 점에 닿을 때 남기는 속도 비율 — 이 값을 곱해 위로 튕긴다
const BOUNCE_LOSS = 0.35;
// 균등 난수 몇 개를 평균 내는지 — 1 이면 칸마다 고르고, 늘수록 가운데가 두꺼워진다
const MEAN_OF = 4;
const DOT_N = 360;
const SPAWN_PER_STEP = 6;
const REST_SPEED = 70;
const DOT_COLS = 3;
const DOT_STEP = 7;
const DOT_R = 2.8;
const SEED = 4708;
const PLOT_L = 34;
const PLOT_R = 446;
const PLOT_T = 52;
const PLOT_B = 264;
const DEMO_LOOP = 2000;
const TXT_IDLE = '구간을 고르면 쌓인 점 수를 읽어 줌';
const TXT_BIN = '구간 ';
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const flatBtn = root.querySelector('[data-role="flat"]') as HTMLButtonElement | null;
const binW = (PLOT_R - PLOT_L) / BIN_COUNT;
type Dot = { x: number; y: number; v: number; bin: number; restY: number; rest: boolean };
let dots: Dot[] = [];
let reserved: number[] = [];
let landed: number[] = [];
let rnd = makeRng(SEED);
let spawned = 0;
let flat = false;
let round = 0;
// 표본 하나 — 균등 난수 n 개의 평균. 여러 개를 더할수록 가운데(0.5) 근처가 잦아진다
function sampleMean(n: number): number {
let s = 0;
for (let i = 0; i < n; i += 1) { s += rnd(); }
return s / n;
}
function reset(): void {
round += 1;
rnd = makeRng(SEED + round);
dots = [];
reserved = [];
landed = [];
for (let b = 0; b < BIN_COUNT; b += 1) { reserved.push(0); landed.push(0); }
spawned = 0;
}
// 점 하나 내보내기 — 칸을 고르고, 그 칸에서 몇 번째 자리에 앉을지 미리 잡아 둔다
function spawn(): void {
const u = sampleMean(flat ? 1 : MEAN_OF);
const bin = Math.min(BIN_COUNT - 1, Math.floor(u * BIN_COUNT));
const slot = reserved[bin];
reserved[bin] += 1;
const col = slot % DOT_COLS;
const row = Math.floor(slot / DOT_COLS);
const x = PLOT_L + bin * binW + (binW - (DOT_COLS - 1) * DOT_STEP) / 2 + col * DOT_STEP;
dots.push({ x: x, y: PLOT_T - 6 - rnd() * 40, v: 0, bin: bin, restY: PLOT_B - DOT_STEP / 2 - row * DOT_STEP, rest: false });
spawned += 1;
}
function step(): void {
const dt = FIXED_DT / 1000;
for (let k = 0; k < SPAWN_PER_STEP && spawned < DOT_N; k += 1) { spawn(); }
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (d.rest) { continue; }
d.v += DROP_GRAVITY * dt;
d.y += d.v * dt;
// 제 자리(쌓인 높이)에 닿으면 튕기거나, 충분히 느리면 멈춰 쌓인다
if (d.y >= d.restY) {
d.y = d.restY;
if (d.v > REST_SPEED) { d.v = -d.v * BOUNCE_LOSS; }
else { d.v = 0; d.rest = true; landed[d.bin] += 1; }
}
}
}
function settle(): void {
while (spawned < DOT_N) { spawn(); }
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (!d.rest) { d.rest = true; d.y = d.restY; d.v = 0; landed[d.bin] += 1; }
}
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(TXT_BIN + (f + 1) + ' · ' + landed[f] + ' / ' + spawned); } else { say(TXT_IDLE); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200], function (): void { reset(); });
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(PAPER, 0.3);
ctx.fillRect(PLOT_L, PLOT_B, PLOT_R - PLOT_L, 1);
const f = focusIdx();
// 막대 — 칸마다 멈춘 점 줄 수만큼의 높이. 쌓인 점 기둥을 그대로 감싼다
for (let b = 0; b < BIN_COUNT; b += 1) {
const rows = Math.ceil(landed[b] / DOT_COLS);
const h = rows * DOT_STEP;
const x = PLOT_L + b * binW + 2;
const c = PAL[b % 3 === 0 ? 0 : (b % 3 === 1 ? 3 : 6)];
ctx.fillStyle = rgba(c, b === f ? 0.42 : 0.2);
barPath(ctx, x, PLOT_B, binW - 4, h + (h > 0 ? 4 : 0), 5);
ctx.fill();
if (b === f) {
ringStyle(b, 1);
ctx.stroke();
ctx.setLineDash([]);
}
if (landed[b] > 0) {
axisText(String(landed[b]), x + (binW - 4) / 2, PLOT_B - h - 12, 'center', b === f ? 1 : 0.7);
}
axisText(String(b + 1), x + (binW - 4) / 2, PLOT_B + 12, 'center', b === f ? 0.95 : 0.45);
}
// 점 — 떨어지는 동안은 밝게, 멈추면 칸 색으로
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
const c = PAL[d.bin % 3 === 0 ? 0 : (d.bin % 3 === 1 ? 3 : 6)];
ctx.fillStyle = d.rest ? rgba(c, 1) : rgba(mix(c, PAPER, 0.55), 1);
ctx.beginPath();
ctx.arc(d.x, d.y, DOT_R, 0, TAU);
ctx.fill();
}
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (flatBtn) { flatBtn.setAttribute('aria-pressed', flat ? 'true' : 'false'); }
}
function hitBin(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 20) { return -1; }
const b = Math.floor((x - PLOT_L) / binW);
return b >= 0 && b < BIN_COUNT ? b : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, BIN_COUNT); }, after);
bindPointer(hitBin, after);
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); reset(); after(); }); }
if (flatBtn) { flatBtn.addEventListener('click', function (): void { wake(); flat = !flat; reset(); after(); }); }
reset();
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="떨어진 점들이 칸마다 튕겼다 멈춰 쌓이며 히스토그램 막대가 되는 그래프. ←/→ 로 구간을 고르면 쌓인 점 수를 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">구간을 고르면 쌓인 점 수를 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="replay" type="button">다시 떨어뜨리기</button>
<button className={cx("bc__btn")} data-role="flat" type="button" aria-pressed="false">균등 난수 하나로</button>
</div>
</div>
);
}08 · 떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대 — 측정 누적면적 24.856% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (d.rest) {
continue;
}
d.v += DROP_GRAVITY * dt;
d.y += d.v * dt;
// 제 자리(쌓인 높이)에 닿으면 튕기거나, 충분히 느리면 멈춰 쌓인다
if (d.y >= d.restY) {
d.y = d.restY;
if (d.v > REST_SPEED) {
d.v = -d.v * BOUNCE_LOSS;
}
else {
d.v = 0;
d.rest = true;
landed[d.bin] += 1;
}
}
}
09증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대
값의 최솟값·최댓값을 0 과 견줘 축 범위가 0 을 빠뜨리지 않게 잡은 뒤 위아래로 범위의 12% 를 더하고, 0 기준선은 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율로 높이를 정한다. 데이터가 바뀌면 축 양 끝을 걸음마다 남은 거리의 10% 씩 옮기므로 1번에서 2번 데이터로 넘어갈 때 기준선이 y 235.3 에서 81.4 로 올라가며 368ms 만에 90% 를 지나고, 0 둘레 ±8 안의 작은 값은 감소색과 증가색이 섞여 회색 쪽으로 넘어간다. 전월 대비 증감을 보여 주는 요약 카드에 맞고, 누적면적은 36.4% 로 기준선과 그 위아래 바탕 띠가 함께 오르내리는 몫이 큰 것으로 보인다.

<main class="stage">
<div class="bc is-demo" id="bc">
<canvas class="bc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="증가는 위로 감소는 아래로 갈라지고 0 기준선이 데이터와 함께 오르내리는 증감 막대 그래프. ←/→ 로 막대를 고르면 이름과 증감을 읽어 준다"></canvas>
<p class="bc__status" data-role="status" aria-live="off">막대를 고르면 증감을 읽어 줌</p>
<div class="bc__tools">
<button class="bc__btn" data-role="next" type="button">다음 데이터</button>
<button class="bc__btn" data-role="flip" type="button" aria-pressed="false">모두 뒤집기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bc {
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: $subject-ink;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 그래프는 아래쪽 판에 있다 — 버튼은 왼쪽 위, 상태 줄은 오른쪽 위 띠에 두어 막대를 덮지 않는다
.bc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.bc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.bc__btn:hover {
background: $subject-cream;
}
.bc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.bc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.bc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
}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);
}
.bc {
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;
}
.bc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.bc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bc__status {
position: absolute;
right: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
text-align: right;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 50%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.bc__btn:hover {
background: #fff7e6;
}
.bc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.bc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.bc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.bc__btn {
font-size: 10px;
padding: 4px 8px;
}
.bc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("bc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG = [24, 26, 38];
const PANEL = [36, 39, 56];
const PAPER = [250, 246, 236];
const INK = [30, 30, 44];
const PAL = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c, x, base, w, h, r) {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c, x0, y, len, th, r) {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text, x, y, align, a) {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx() { return sel >= 0 ? sel : pinned; }
function nextIdx(cur, d, n) {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i, a) {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move, changed) {
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit, changed) {
cv.addEventListener('pointermove', function (ev) {
if (demo) {
return;
}
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) {
sel = i;
changed();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 16ms 걸음마다 축 위·아래 끝(그래서 0 기준선 높이)이 새 목표와의 남은 거리 중 이 비율만큼 다가간다
const BASELINE_LERP = 0.1;
// 0 을 지나는 막대의 색 섞기 폭(값 단위 %p) — −이 값 ~ +이 값 사이에서 감소색이 증가색으로 넘어간다
const ZERO_CROSS_FADE = 8;
// 축 위아래 여백 — 값 범위의 이 백분율만큼 바깥으로 넓혀 막대 끝 글자가 판에 붙지 않게
const DIVERGE_PAD_PCT = 12;
const BAR_N = 8;
const SEED = 4709;
const PLOT_L = 52;
const PLOT_R = 462;
const PLOT_T = 58;
const PLOT_B = 272;
const BAR_GAP = 16;
const VALUE_LERP = 0.12;
const DEMO_LOOP = 2000;
const GAIN = [130, 224, 190];
const LOSS = [255, 160, 150];
const TXT_IDLE = '막대를 고르면 증감을 읽어 줌';
const nextBtn = root.querySelector('[data-role="next"]');
const flipBtn = root.querySelector('[data-role="flip"]');
// 가상 증감 묶음 — 대체로 오름·대체로 내림·섞임 세 벌. 범위 [lo, hi] 안에서 시드 난수
function makeSet(sd, lo, hi) {
const rnd = makeRng(sd);
const out = [];
for (let i = 0; i < BAR_N; i += 1) {
out.push(Math.round((lo + rnd() * (hi - lo)) * 10) / 10);
}
return out;
}
const sets = [makeSet(SEED, -8, 46), makeSet(SEED + 1, -44, 9), makeSet(SEED + 2, -30, 30)];
let setIdx = 0;
let flipped = false;
const vals = [];
for (let i = 0; i < BAR_N; i += 1) {
vals.push(0);
}
function goalOf(i) { return sets[setIdx][i] * (flipped ? -1 : 1); }
// 축 범위 — 0 을 반드시 품고(min(0,·)·max(0,·)) 값 범위의 DIVERGE_PAD_PCT 만큼 위아래로 넓힌다
function rangeFor() {
let lo = 0;
let hi = 0;
for (let i = 0; i < BAR_N; i += 1) {
lo = Math.min(lo, goalOf(i));
hi = Math.max(hi, goalOf(i));
}
const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
return { lo: lo - pad, hi: hi + pad };
}
const axis = rangeFor();
// 0 기준선 높이 — 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율
function zeroY() { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
function yOf(v) { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
// 증감 색 — 값이 0 근처 ±ZERO_CROSS_FADE 안이면 두 색을 부드럽게 섞는다
function signColor(v) { return mix(LOSS, GAIN, smooth((v / ZERO_CROSS_FADE + 1) / 2)); }
function signed(v) { return (v > 0 ? '+' : '') + v.toFixed(1) + '%'; }
function step() {
const r = rangeFor();
axis.lo += (r.lo - axis.lo) * BASELINE_LERP;
axis.hi += (r.hi - axis.hi) * BASELINE_LERP;
for (let i = 0; i < BAR_N; i += 1) {
vals[i] += (goalOf(i) - vals[i]) * VALUE_LERP;
}
}
function settle() {
const r = rangeFor();
axis.lo = r.lo;
axis.hi = r.hi;
for (let i = 0; i < BAR_N; i += 1) {
vals[i] = goalOf(i);
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
say(LABELS[f] + ' · ' + signed(goalOf(f)));
}
else {
say(TXT_IDLE);
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 850, 1500], function (k) {
setIdx = k;
flipped = false;
report();
});
}
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
function draw() {
clearStage();
const zy = zeroY();
// 기준선 위는 증가 쪽, 아래는 감소 쪽 — 바탕 색 띠가 기준선과 함께 오르내린다
ctx.fillStyle = rgba(GAIN, 0.07);
ctx.fillRect(PLOT_L, PLOT_T, PLOT_R - PLOT_L, zy - PLOT_T);
ctx.fillStyle = rgba(LOSS, 0.07);
ctx.fillRect(PLOT_L, zy, PLOT_R - PLOT_L, PLOT_B - zy);
const span = axis.hi - axis.lo;
const stepV = span > 60 ? 20 : 10;
for (let v = Math.ceil(axis.lo / stepV) * stepV; v <= axis.hi; v += stepV) {
if (v === 0) {
continue;
}
const y = yOf(v);
ctx.fillStyle = rgba(PAPER, 0.07);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(signed(v).replace('.0', ''), PLOT_L - 8, y, 'right', 0.45);
}
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const v = vals[i];
const h = zy - yOf(v);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(signColor(v), dim ? 0.5 : 1);
barPath(ctx, x, zy, barW, h, 5);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
// 값 글자 — 막대 끝 바깥, 위로 뻗으면 위에 아래로 뻗으면 아래에
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = v >= 0 ? 'bottom' : 'top';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(signed(v), x + barW / 2, v >= 0 ? zy - h - 4 : zy - h + 4);
axisText(LABELS[i], x + barW / 2, v >= 0 ? zy + 10 : zy - 10, 'center', i === f ? 0.95 : 0.5);
}
// 0 기준선 — 맨 위에 밝게
ctx.fillStyle = rgba(PAPER, 0.85);
ctx.fillRect(PLOT_L, Math.round(zy) - 1, PLOT_R - PLOT_L, 2);
axisText('0', PLOT_L - 8, zy, 'right', 0.9);
}
function after() {
if (still) {
settle();
}
report();
draw();
if (flipBtn) {
flipBtn.setAttribute('aria-pressed', flipped ? 'true' : 'false');
}
}
function hitBar(x, y) {
if (y < PLOT_T - 20 || y > PLOT_B + 20) {
return -1;
}
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d) { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (nextBtn) {
nextBtn.addEventListener('click', function () { wake(); setIdx = (setIdx + 1) % sets.length; after(); });
}
if (flipBtn) {
flipBtn.addEventListener('click', function () { wake(); flipped = !flipped; after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion() {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref() {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
startMotion();
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// BarDivergingZeroBaseline - vanilla 판(09-bar-chart-diverging-zero-baseline) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BarDivergingZeroBaseline.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BarDivergingZeroBaseline() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막대를 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [24, 26, 38];
const PANEL: RGB = [36, 39, 56];
const PAPER: RGB = [250, 246, 236];
const INK: RGB = [30, 30, 44];
const PAL: RGB[] = [
[126, 190, 255],
[255, 164, 190],
[130, 224, 190],
[192, 168, 255],
[255, 206, 160],
[244, 226, 140],
[150, 220, 236],
[230, 184, 226],
[196, 220, 160],
[176, 196, 236],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I', '항목 J'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 움직임 줄이기 — 열린 페이지에서 설정을 바꿔도 아래 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 세로 막대 경로 — 기준선 base 에서 h 만큼(음수면 아래로) 뻗고, 끝 모서리만 둥글린다
function barPath(c: CanvasRenderingContext2D, x: number, base: number, w: number, h: number, r: number): void {
const rr = Math.min(r, Math.abs(h), w / 2);
const tip = base - h;
const s = h >= 0 ? 1 : -1;
c.beginPath();
c.moveTo(x, base);
c.lineTo(x, tip + s * rr);
c.quadraticCurveTo(x, tip, x + rr, tip);
c.lineTo(x + w - rr, tip);
c.quadraticCurveTo(x + w, tip, x + w, tip + s * rr);
c.lineTo(x + w, base);
c.closePath();
}
// 가로 막대 경로 — 왼쪽 x0 에서 len 만큼 뻗고 오른쪽 끝만 둥글린다
function hbarPath(c: CanvasRenderingContext2D, x0: number, y: number, len: number, th: number, r: number): void {
const rr = Math.min(r, Math.max(len, 0), th / 2);
const x1 = x0 + Math.max(len, 0);
c.beginPath();
c.moveTo(x0, y);
c.lineTo(x1 - rr, y);
c.quadraticCurveTo(x1, y, x1, y + rr);
c.lineTo(x1, y + th - rr);
c.quadraticCurveTo(x1, y + th, x1 - rr, y + th);
c.lineTo(x0, y + th);
c.closePath();
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
function axisText(text: string, x: number, y: number, align: CanvasTextAlign, a: number): void {
ctx.font = '500 10px sans-serif';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, a);
ctx.fillText(text, x, y);
}
// 고른 막대 — 화살표 키·포인터가 가리키는 sel 과 Enter·Space·클릭으로 고정한 pinned
let sel = -1;
let pinned = -1;
function focusIdx(): number { return sel >= 0 ? sel : pinned; }
function nextIdx(cur: number, d: number, n: number): number {
return cur < 0 ? (d > 0 ? 0 : n - 1) : (cur + d + n) % n;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
// 고른 막대 테두리 — 화살표·포인터로 가리키기만 하면 점선, Enter·Space·클릭으로 고정하면 실선
function ringStyle(i: number, a: number): void {
ctx.strokeStyle = rgba(PAPER, 0.95 * a);
ctx.lineWidth = 2;
ctx.setLineDash(i === pinned ? [] : [4, 3]);
}
// 키보드 — ←/→ 막대 옮기기, Enter·Space 고르기, Esc 풀기. 셋 다 상태 줄이 이름과 값을 읽는다
function bindKeys(move: (d: number) => void, changed: () => void): void {
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
move(ev.key === 'ArrowRight' ? 1 : -1);
changed();
} else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
changed();
} else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
sel = -1;
pinned = -1;
changed();
}
});
}
// 포인터 — 올리면 sel, 누르면 그 막대를 고정(같은 막대를 다시 누르면 풀기)
function bindPointer(hit: (x: number, y: number) => number, changed: () => void): void {
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (demo) { return; }
const p = toScene(ev);
const i = hit(p[0], p[1]);
if (i !== sel) { sel = i; changed(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hit(p[0], p[1]);
sel = i;
togglePin(i);
changed();
});
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 16ms 걸음마다 축 위·아래 끝(그래서 0 기준선 높이)이 새 목표와의 남은 거리 중 이 비율만큼 다가간다
const BASELINE_LERP = 0.1;
// 0 을 지나는 막대의 색 섞기 폭(값 단위 %p) — −이 값 ~ +이 값 사이에서 감소색이 증가색으로 넘어간다
const ZERO_CROSS_FADE = 8;
// 축 위아래 여백 — 값 범위의 이 백분율만큼 바깥으로 넓혀 막대 끝 글자가 판에 붙지 않게
const DIVERGE_PAD_PCT = 12;
const BAR_N = 8;
const SEED = 4709;
const PLOT_L = 52;
const PLOT_R = 462;
const PLOT_T = 58;
const PLOT_B = 272;
const BAR_GAP = 16;
const VALUE_LERP = 0.12;
const DEMO_LOOP = 2000;
const GAIN: RGB = [130, 224, 190];
const LOSS: RGB = [255, 160, 150];
const TXT_IDLE = '막대를 고르면 증감을 읽어 줌';
const nextBtn = root.querySelector('[data-role="next"]') as HTMLButtonElement | null;
const flipBtn = root.querySelector('[data-role="flip"]') as HTMLButtonElement | null;
// 가상 증감 묶음 — 대체로 오름·대체로 내림·섞임 세 벌. 범위 [lo, hi] 안에서 시드 난수
function makeSet(sd: number, lo: number, hi: number): number[] {
const rnd = makeRng(sd);
const out: number[] = [];
for (let i = 0; i < BAR_N; i += 1) { out.push(Math.round((lo + rnd() * (hi - lo)) * 10) / 10); }
return out;
}
const sets: number[][] = [makeSet(SEED, -8, 46), makeSet(SEED + 1, -44, 9), makeSet(SEED + 2, -30, 30)];
let setIdx = 0;
let flipped = false;
const vals: number[] = [];
for (let i = 0; i < BAR_N; i += 1) { vals.push(0); }
function goalOf(i: number): number { return sets[setIdx][i] * (flipped ? -1 : 1); }
type Range = { lo: number; hi: number };
// 축 범위 — 0 을 반드시 품고(min(0,·)·max(0,·)) 값 범위의 DIVERGE_PAD_PCT 만큼 위아래로 넓힌다
function rangeFor(): Range {
let lo = 0;
let hi = 0;
for (let i = 0; i < BAR_N; i += 1) { lo = Math.min(lo, goalOf(i)); hi = Math.max(hi, goalOf(i)); }
const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
return { lo: lo - pad, hi: hi + pad };
}
const axis: Range = rangeFor();
// 0 기준선 높이 — 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율
function zeroY(): number { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
function yOf(v: number): number { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
// 증감 색 — 값이 0 근처 ±ZERO_CROSS_FADE 안이면 두 색을 부드럽게 섞는다
function signColor(v: number): RGB { return mix(LOSS, GAIN, smooth((v / ZERO_CROSS_FADE + 1) / 2)); }
function signed(v: number): string { return (v > 0 ? '+' : '') + v.toFixed(1) + '%'; }
function step(): void {
const r = rangeFor();
axis.lo += (r.lo - axis.lo) * BASELINE_LERP;
axis.hi += (r.hi - axis.hi) * BASELINE_LERP;
for (let i = 0; i < BAR_N; i += 1) { vals[i] += (goalOf(i) - vals[i]) * VALUE_LERP; }
}
function settle(): void {
const r = rangeFor();
axis.lo = r.lo;
axis.hi = r.hi;
for (let i = 0; i < BAR_N; i += 1) { vals[i] = goalOf(i); }
}
function report(): void {
const f = focusIdx();
if (f >= 0) { say(LABELS[f] + ' · ' + signed(goalOf(f))); } else { say(TXT_IDLE); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 850, 1500], function (k: number): void {
setIdx = k;
flipped = false;
report();
});
}
const slotW = (PLOT_R - PLOT_L) / BAR_N;
const barW = slotW - BAR_GAP;
function draw(): void {
clearStage();
const zy = zeroY();
// 기준선 위는 증가 쪽, 아래는 감소 쪽 — 바탕 색 띠가 기준선과 함께 오르내린다
ctx.fillStyle = rgba(GAIN, 0.07);
ctx.fillRect(PLOT_L, PLOT_T, PLOT_R - PLOT_L, zy - PLOT_T);
ctx.fillStyle = rgba(LOSS, 0.07);
ctx.fillRect(PLOT_L, zy, PLOT_R - PLOT_L, PLOT_B - zy);
const span = axis.hi - axis.lo;
const stepV = span > 60 ? 20 : 10;
for (let v = Math.ceil(axis.lo / stepV) * stepV; v <= axis.hi; v += stepV) {
if (v === 0) { continue; }
const y = yOf(v);
ctx.fillStyle = rgba(PAPER, 0.07);
ctx.fillRect(PLOT_L, Math.round(y), PLOT_R - PLOT_L, 1);
axisText(signed(v).replace('.0', ''), PLOT_L - 8, y, 'right', 0.45);
}
const f = focusIdx();
for (let i = 0; i < BAR_N; i += 1) {
const x = PLOT_L + i * slotW + BAR_GAP / 2;
const v = vals[i];
const h = zy - yOf(v);
const dim = f >= 0 && f !== i;
ctx.fillStyle = rgba(signColor(v), dim ? 0.5 : 1);
barPath(ctx, x, zy, barW, h, 5);
ctx.fill();
if (i === f) {
ringStyle(i, 1);
ctx.stroke();
ctx.setLineDash([]);
}
// 값 글자 — 막대 끝 바깥, 위로 뻗으면 위에 아래로 뻗으면 아래에
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = v >= 0 ? 'bottom' : 'top';
ctx.fillStyle = rgba(PAPER, dim ? 0.45 : 0.92);
ctx.fillText(signed(v), x + barW / 2, v >= 0 ? zy - h - 4 : zy - h + 4);
axisText(LABELS[i], x + barW / 2, v >= 0 ? zy + 10 : zy - 10, 'center', i === f ? 0.95 : 0.5);
}
// 0 기준선 — 맨 위에 밝게
ctx.fillStyle = rgba(PAPER, 0.85);
ctx.fillRect(PLOT_L, Math.round(zy) - 1, PLOT_R - PLOT_L, 2);
axisText('0', PLOT_L - 8, zy, 'right', 0.9);
}
function after(): void {
if (still) { settle(); }
report();
draw();
if (flipBtn) { flipBtn.setAttribute('aria-pressed', flipped ? 'true' : 'false'); }
}
function hitBar(x: number, y: number): number {
if (y < PLOT_T - 20 || y > PLOT_B + 20) { return -1; }
const i = Math.floor((x - PLOT_L) / slotW);
return i >= 0 && i < BAR_N ? i : -1;
}
bindKeys(function (d: number): void { sel = nextIdx(focusIdx(), d, BAR_N); }, after);
bindPointer(hitBar, after);
if (nextBtn) { nextBtn.addEventListener('click', function (): void { wake(); setIdx = (setIdx + 1) % sets.length; after(); }); }
if (flipBtn) { flipBtn.addEventListener('click', function (): void { wake(); flipped = !flipped; after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startMotion(): void {
window.cancelAnimationFrame(rafId);
lastTs = -1;
acc = 0;
if (still) {
// 움직임 줄이기 — 루프를 돌리지 않고 지금 목표 상태로 바로 그린다
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
}
// 열린 페이지에서 움직임 줄이기 설정이 바뀌어도 그 자리에서 루프를 멈추거나 다시 켠다
function onMotionPref(): void {
still = reduceQuery.matches;
startMotion();
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
startMotion();
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("bc") + " is-demo"} ref={rootRef}>
<canvas className={cx("bc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="증가는 위로 감소는 아래로 갈라지고 0 기준선이 데이터와 함께 오르내리는 증감 막대 그래프. ←/→ 로 막대를 고르면 이름과 증감을 읽어 준다"></canvas>
<p className={cx("bc__status")} data-role="status" aria-live="off">막대를 고르면 증감을 읽어 줌</p>
<div className={cx("bc__tools")}>
<button className={cx("bc__btn")} data-role="next" type="button">다음 데이터</button>
<button className={cx("bc__btn")} data-role="flip" type="button" aria-pressed="false">모두 뒤집기</button>
</div>
</div>
);
}09 · 증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대 — 측정 누적면적 36.38% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 축 범위 — 0 을 반드시 품고(min(0,·)·max(0,·)) 값 범위의 DIVERGE_PAD_PCT 만큼 위아래로 넓힌다
function rangeFor() {
let lo = 0;
let hi = 0;
for (let i = 0; i < BAR_N; i += 1) {
lo = Math.min(lo, goalOf(i));
hi = Math.max(hi, goalOf(i));
}
const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
return { lo: lo - pad, hi: hi + pad };
}
const axis = rangeFor();
// 0 기준선 높이 — 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율
function zeroY() { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
function yOf(v) { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
값 하나는 어떻게 화면 위 막대 높이 몇 픽셀이 되는가?
막대 높이는 값을 축 끝 값으로 나눈 비율에 그래프 판의 높이를 곱한 길이다. 01 은 판이 y 64 에서 262 까지 198px 이고 축이 0~100 이라 값 1 이 1.98px 가 되고, 목표 60 인 막대는 바닥에서 118.8px 솟는다. 막대 폭은 판 너비 418px 를 여덟 칸으로 나눈 52.25px 에서 사이 간격 14px 를 뺀 38.25px 다.
축 끝 값이 바뀌면 같은 값도 다른 높이가 된다
이 비례식에서 움직이는 것은 값만이 아니다. 05 는 나누는 수인 축 끝 값을 600ms 동안 50 에서 150 으로 옮기는데, 100ms 간격으로 보면 59.2 → 81.4 → 109 → 134 → 148.7 로 가운데가 빠른 곡선을 그린다. 값이 그대로라면 같은 막대의 높이가 3분의 1 로 줄어드는 셈이다. 막대만 따로 줄이지 않고 축과 같은 수로 나누기 때문에 눈금선과 막대 끝이 어긋나지 않는다.
가로 막대는 같은 식을 x 축으로 돌린 것
가로 막대 그래프 만들기도 비례식은 같고 길이를 재는 방향만 바뀐다. 04 는 왼쪽 시작점 x 70 에서 오른쪽 끝 452 까지를 0~26,000 에 맞춰 값 1 을 0.01469px 로 바꾸므로, 15,940 은 234px 짜리 막대가 된다.
0 이 판 가운데에 오는 증감 막대
09 처럼 음수가 섞이면 바닥이 판 아래 끝이 아니라 0 의 자리로 옮겨 간다. 기준선 y 는 58 + hi ÷ (hi − lo) × 214 로, 첫 데이터는 축이 −10.78~52.08 이라 0 이 위에서 82.8% 지점인 y 235.3 에 놓이고, 둘째 데이터는 −49.03~6.03 이라 10.9% 지점인 y 81.4 까지 올라간다.
화면이 커져도 좌표가 480×300 인 까닭
캔버스 안 좌표는 아홉 판 모두 480×300 으로 고정이고, 실제 화소만 Math.min(window.devicePixelRatio || 1, 2) 배로 늘린다. 포인터 위치도 getBoundingClientRect 비율로 이 좌표에 되돌려 계산하니, 무대가 폰 폭으로 줄어도 위에 적은 픽셀 수는 같은 식으로 읽으면 된다.
눈금을 1·2·5 단위로 고르는 계산과, 막대가 목표 높이를 한 번 넘었다 돌아오는 이유
축 눈금이 23.6 이나 184.4 같은 간격으로 나오면 읽는 사람이 막대 높이를 머릿속으로 나누기 어렵다. 05 는 대략의 간격을 먼저 구하고 그 수를 10의 거듭제곱으로 눌러 1 과 10 사이의 수로 만든 뒤, 1·2·5·10 가운데 그보다 크거나 같은 첫 값을 골라 다시 거듭제곱을 곱한다.
05 의 네 단계에서 고른 간격
| 단계 | 막대 최댓값 | 대략의 간격 | 고른 간격 | 축 끝 |
|---|---|---|---|---|
| 처음 값 | 46 | 9.2 | 10 | 50 |
| 1번 늘림 | 118 | 23.6 | 50 | 150 |
| 2번 늘림 | 405 | 81 | 100 | 500 |
| 3번 늘림 | 922 | 184.4 | 200 | 1,000 |
118 은 23.6 을 10 으로 나눈 2.36 이 2 를 넘어서 5 로 올라가 간격 50 이 되고, 922 는 1.844 가 2 로 올라가 간격 200 이 된다. 같은 함수에 다른 값을 넣으면 7 은 간격 2·축 8, 1,234 는 간격 500·축 1,500, 4,321 은 간격 1,000·축 5,000 이 나온다. 축 끝은 최댓값을 간격으로 나눠 올림한 칸 수에 간격을 곱한 값이라, 922 의 축이 1,000 에서 멈추듯 가장 높은 막대 바로 위 눈금에서 판이 끝난다.
감쇠비가 1 보다 작으면 목표를 한 번 지나친다
01 의 가속도는 −강성 × (높이 − 목표) − 감쇠 × 속도 하나뿐이다. 감쇠 ÷ (2√강성) 으로 구하는 감쇠비가 1 보다 작으면 막대는 목표에 닿는 순간에도 위로 가는 속도가 남아 있어 그만큼 지나쳤다가 당겨져 돌아오고, 170 과 12 의 감쇠비 0.46 에서는 그 넘침이 17.7% 다. 목표 ±0.5 띠에 처음 들어가는 것은 416ms 지만 한 번 더 빠져나갔다가 640ms 부터 머문다.
속도부터 고치는 순서가 버티는 이유
springStep 은 가속도로 속도를 먼저 바꾸고 그 새 속도로 높이를 옮긴다. 옛 속도로 높이부터 옮기는 순서보다 16ms 고정 걸음에서 덜 튀는 쪽이라 이 순서를 골랐고, 감쇠 12 에서는 출렁임이 차츰 줄어 640ms 부터 목표 ±0.5 안에 머문다.
막대가 수백 개일 때·값이 0 근처일 때·라벨이 막대보다 길 때 — 막대 그래프가 깨지는 곳
막대가 수백 개면 어안 가장자리가 1px 밑으로 사라진다
06 은 폭 합계가 432px 로 묶여 있어 가운데를 넓힌 만큼 반경 가장자리가 눌리고, 막대 60개에서도 가장 가는 막대가 2.0px 까지 줄어 막대 사이 틈은 0.4px 만 남는다. 432px 를 막대 수로 나눈 원래 폭은 200개만 돼도 2.2px 라, 막대를 수백 개로 늘리면 눌린 막대가 1px 밑으로 내려가 안티에일리어싱 번짐에 묻힐 것으로 보인다. 02 처럼 걸음마다 전부 정렬하는 판도 막대가 늘수록 정렬 비용이 커지니, 그때는 키프레임이 바뀔 때만 다시 정렬하는 식으로 줄인다.
같은 값이 둘이면 경주 순서는 정렬 구현이 정한다
02 의 비교 함수는 cur[b] - cur[a] 하나라 값이 같으면 0 을 돌려주고, 그 둘의 순서는 브라우저 정렬에 맡겨진다. 시드 데이터도 첫 키프레임에 34 가 셋이라 시작 줄 순서가 여기에 기대고 있으니, 내 데이터에 동점이 잦다면 이름순 같은 두 번째 기준을 비교에 덧붙인다.
최댓값이 0 이거나 값이 전부 0 일 때
05 는 최댓값이 0 이면 Math.log10(0) 이 음의 무한대가 되어 간격이 NaN 으로 무너지고, 09 는 값이 전부 0 이면 hi − lo 가 0 이라 기준선 식의 나눗셈이 깨진다. 데모 값은 05 가 12 이상이라 터지지 않지만 실제 보고서에는 기록이 빈 달이 나올 수 있으니 0 을 따로 걸러야 한다. 09 는 ±8 안의 작은 증감이 회색 쪽으로 섞여 색만으로는 부호가 흐려지므로, 이름표에 +/− 를 붙여 색 말고도 부호를 읽게 했다.
숫자 글자가 막대보다 길 때
04 에서 쉼표까지 다섯 글자인 「3,297」 은 31.0px, 여섯 글자인 「15,940」 은 37.9px 로 재여 양옆 여백을 더하면 47.0px·53.9px 가 있어야 막대 안에 들어간다. 「막대 줄이기」 로 값을 0.3 배로 낮추면 일곱 줄이 모두 바깥으로 나가고, 막대가 가장 짧은 줄은 6px 이다. 글자 폭은 measureText 가 그 순간 쓰는 글꼴로 재므로 웹 글꼴을 쓴다면 글꼴이 늦게 들어오는 동안 판정이 달라질 수 있다(이 데모는 sans-serif 그대로다).
흐리게 낮춘 줄도 원래 색으로 대비를 잰다
04 에서 줄 하나를 고르면 나머지 줄이 흐려지는데, 글자 색 판정은 흐리기 전 막대 색으로 한다. 그래서 흐린 줄 글자의 실제 대비는 계산값보다 낮아지고, 반투명 막대를 쓰려면 바탕과 섞인 뒤의 색으로 휘도를 다시 재야 한다.
음수와 얇은 층은 누적 막대를 어긋나게 한다
03 의 누적합은 양수만 가정하므로 음수를 넣으면 층의 시작 높이가 아래로 꺾여 층끼리 겹친다. 계열이 많아 층이 2px 틈보다 얇아지면 틈만 보이게 되므로, 이 판은 층 높이가 2px 보다 작을 때 틈을 그 높이만큼만 연다.
칠하는 순서 하나로 가림을 맞추는 한계
07 은 gx + gy 가 작은 칸부터 칠하는 순서 하나로 앞뒤를 맞추기 때문에 칸 크기가 다른 기둥이나 서로 겹치는 기둥에서는 가림이 틀린다. 그림자 길이는 높이에 (Lx, Ly) ÷ Lz 를 곱한 값이라 빛 고도를 0° 쪽으로 내리면 Lz 가 0 에 다가가 그림자가 끝없이 길어지고, 그림자는 바닥에만 눕고 다른 기둥 몸통을 타고 오르지 않는다.
감쇠를 올리면 넘침이, 강성을 올리면 안정이 사라진다
01 은 감쇠를 26 쯤으로 올리면 감쇠비가 1 에 닿아 넘침이 없어지면서 이 판 이름의 동작이 사라지고, 강성을 크게 올리면 16ms 고정 걸음에서 수치가 튈 수 있다. 출렁이다 0 아래로 내려간 높이는 Math.max(0, b.x) 로 잘라 막대가 바닥을 뚫고 내려가지 않게 했다.
점끼리는 서로 부딪치지 않는다
08 은 점이 나오는 순간 그 칸에서 앉을 자리를 먼저 예약하므로, 늦게 나온 점이 아직 떨어지고 있는 점을 지나쳐 제 자리로 내려간다. 점을 수만 개로 늘리면 멈춘 점은 계산을 건너뛰어도 그리기는 전부 다시 하므로 그리는 쪽 비용이 먼저 커진다.
판마다 index.html·style.scss·style.css 를 담은 vanilla 폴더와 같은 계산을 옮긴 react 폴더를 묶어 둔 파일은 jm3zs7sw 을 넣어야 풀리고, 위에서 본 함정을 내 데이터로 직접 재현해 볼 때 쓰면 된다.
스프링 감쇠·순위 바뀌는 빠르기·어안 반경을 옮기는 변형, 그리고 키보드와 상태 줄로 값을 읽히는 접근성
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 스프링 감쇠 키우기 | 01 SPRING_DAMPING 12 → 26 | 감쇠비가 0.46 에서 0.997 로 1 에 거의 닿아 목표 60 막대가 70.6 까지 솟던 넘침이 없어지고, 막대가 곧게 올라와 592ms 에 자리 잡는다(12 일 때는 640ms). |
| 순위 따라가는 빠르기 올리기 | 02 RANK_LERP 0.12 → 0.3 | 한 줄 차이를 10% 로 줄이는 데 19걸음 304ms 이던 것이 7걸음 112ms 로 짧아져 새 줄에 거의 바로 붙고, 겹쳐 지나가는 추월 순간은 덜 보인다. 둘 다 키프레임 간격 420ms 안이다. |
| 어안 반경 넓히기 | 06 FISHEYE_RADIUS_PX 80 → 160 | 넓어지는 막대가 8개에서 14개, 눌리는 막대가 16개에서 32개로 늘고 가장 넓은 막대는 22.7px 에서 25.4px 가 되어 한 구간이 통째로 펼쳐진다. 폭이 그대로인 막대는 36개에서 14개로 준다. |
마우스 없이 막대를 고르는 키
아홉 캔버스 모두 tabindex="0" 과 한국어 aria-label 을 달아 탭으로 들어갈 수 있다. ←/→ 는 막대를 옮기고(02 는 지금 순위 차례, 07 은 칸 번호 차례), Enter·Space 는 그 막대를 고정하거나 같은 막대면 풀며, Escape 는 고정을 푼다. 셋 다 기본 동작을 막은 뒤 오른쪽 위 상태 줄이 「항목 C · 87」, 04 는 「항목 D · 15,940 · 바깥 4.0:1」 처럼 이름과 값을 적는다.
가리킨 막대와 고정한 막대의 테두리
포인터를 올리기만 한 막대는 [4, 3] 간격의 점선, 눌러 고정한 막대는 실선 테두리를 둘러 색을 구분하지 못해도 상태가 갈린다. 캔버스와 버튼의 키보드 포커스 테두리는 style.css 의 :focus-visible 이 그린다.
움직임 줄이기를 켜 둔 화면
아홉 판 모두 matchMedia('(prefers-reduced-motion: reduce)') 에 change 리스너를 걸어, 페이지를 연 채로 설정을 바꿔도 곧바로 따른다. 줄이기 쪽이면 애니메이션 루프를 세우고 목표 상태로 바로 옮겨 한 번 그리며, 그 상태에서도 키·버튼·클릭은 결과를 화면에 바로 반영한다. 02 는 이때 처음 화면이 마지막 키프레임이라 일시 정지 버튼은 화면은 그대로 두고 눌림 상태만 바꾸며, style.css 의 같은 미디어 쿼리는 버튼 배경색 전환까지 끈다.
css 막대 그래프 애니메이션만으로는 어디까지 되고 어디서 캔버스가 필요한가?
정해 둔 높이까지 막대 몇 개를 키우는 정도라면 CSS 의 높이 전환만으로도 충분하다. 다만 이 편의 아홉 판은 CSS 가 아니라 캔버스와 스크립트로 만들었고, 그 까닭은 판마다 값을 읽어 다음 화면을 정하는 계산이 들어가서다. 02 는 16ms 마다 값을 정렬해 순위를 다시 매기고, 04 는 글자 폭과 휘도를 재서 라벨 자리를 고르며, 05 는 최댓값에서 눈금 간격을 구한다. 06 의 경계 재배치, 07 의 면 법선과 빛 내적, 08 의 중력 적분도 매 걸음 숫자를 다시 계산해야 해서 스크립트의 몫이 된다. 막대는 fillRect 와 경로 채우기로, 숫자 폭은 measureText 로, 시간은 requestAnimationFrame 으로 다뤘다.
같은 데이터 시각화 줄기에서 원 조각을 다루는 편은 원형 그래프 만들기 9종, 선과 막대를 SVG 로 긋는 편은 SVG 그래프 9종 이다. 캔버스 차트 전반은 캔버스 차트 애니메이션 9종 과 데이터 시각화 차트 9종, 화소 계산으로 장면을 채우는 쪽은 캔버스 특수효과 9종 으로 이어진다.
막대 그래프를 보고서 화면에 붙이기 전에 자주 확인하는 것
가로 막대 그래프 만들기에도 01 의 스프링을 그대로 쓸 수 있나?
쓸 수 있다. springStep 은 높이·속도·목표 숫자 세 개만 다루고 방향을 모르니, 04 의 막대 길이에 붙여도 같은 넘침이 난다. 04 는 지금 길이를 목표 쪽으로 걸음마다 12% 씩 당겨 넘치지 않고, 걸음마다 지금 길이 len[i] 로 라벨 안팎을 다시 판정한다. 그래서 스프링으로 바꾸면 길이가 출렁이는 동안 판정도 흔들릴 수 있으니, 판정에만 목표 길이를 넘기는 편이 낫다.
내 숫자로 바꾸려면 어디를 고치나?
01 은 seededValues(seed, BAR_N, 30, 94) 로 만든 목표 배열을 내 값 여덟 개로 바꾸면 된다. 01 의 축은 0~100 으로 고정이라 100 을 넘는 값이면 AXIS_MAX 를 함께 고치거나, 05 의 niceStep·niceMax 를 가져와 축 끝을 값에서 구한다. 09 는 축과 기준선을 값에서 계산하므로 sets 의 배열만 바꿔도 0 의 자리가 따라온다.
화면 낭독기 사용자는 막대 값을 어떻게 듣나?
캔버스 안의 막대는 하나씩 읽히는 요소가 아니어서, 이 데모는 캔버스에 aria-label 한 줄을 달고 고른 막대의 값은 상태 줄로 전한다. 상태 줄의 aria-live 는 자동 시연 동안 off 로 두어 시연이 바뀔 때마다 읽히지 않게 하고, 사람이 처음 키·버튼·포인터를 누르는 순간 polite 로 바뀐다. 보고서처럼 전체 숫자를 한 번에 확인해야 하는 화면이라면 그래프 옆이나 아래에 같은 값을 담은 table 을 함께 두는 편이 낫고, 이 데모에는 그 표가 들어 있지 않다.








