棒グラフ アニメーション 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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 目標の高さを一度越えてから戻る、ばねで伸びる棒
- 02 順位が入れ替わると棒が列を移って走る棒グラフの競走
- 03 下の層から順に積み上がる積み上げ棒グラフ
- 04 ラベルが棒の内か外かを自分で決める横棒グラフ
- 05 値が大きくなると目盛りが1・2・5の刻みで取り直され、棒と一緒に縮む軸
- 06 何十本もの棒のうち、ポインターの近くだけ魚眼レンズのように広がる棒グラフ
- 07 光の向きで上面と側面の明暗が変わる等角3Dの柱
- 08 落ちてきた粒がマスごとに積もってヒストグラムになる棒
- 09 増加は上へ・減少は下へ分かれ、ゼロの基準線も一緒に動く増減の棒
棒の高さに使った数字の出どころ
棒の高さはすべて、32ビット整数をかき混ぜるmakeRng(seed)から取り出した架空の値です。シードを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枚です。すぐ隣の2枚どうしを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ずつずれて8本目の棒は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="架空の値8本の棒が減衰ばねで伸び、目標の高さを一度越えてから戻る棒グラフ。←/→ で棒を選ぶと名前と値を読み上げます"></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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 毎ステップの並べ替え — 値の大きい順に順位を付け直し、順位が上がった棒は一瞬光る
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="4つの系列を累積和で積み、下の層から時間差をつけて満ちていく積み上げ棒グラフ。←/→ で棒を選ぶと名前と合計を読み上げます"></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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 層 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 相対輝度 — 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の刻みで取り直され、棒と一緒に縮む軸
棒の最大値を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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 魚眼関数 — ポインターから 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.7を掛けて環境光0.3の上に足し、面ごとの明るさを決め、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 等角投影 — 床 (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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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を外さないように取ってから上下に範囲の12%を足し、ゼロの基準線は軸の上端から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="増加は上へ、減少は下へ分かれ、ゼロの基準線がデータと一緒に上下する増減の棒グラフ。←/→ で棒を選ぶと名前と増減を読み上げます"></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 · 増加は上へ・減少は下へ分かれ、ゼロの基準線も一緒に動く増減の棒 — 計測 累積面積 36.38% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 軸の範囲 — 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までが範囲全体に占める割合
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を8つに分けた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の棒になります。
ゼロが盤の真ん中に来る増減の棒
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の内側に留まります。
棒が数百本・値がゼロの近く・ラベルが棒より長い — 棒グラフが壊れるところ
棒が数百本になると魚眼の縁が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でコンマまで数えて5文字の「3,297」は31.0px、6文字の「15,940」は37.9pxと測られ、左右の余白を足すと47.0px・53.9pxなければ棒の中に入りません。「棒を短くする」で値を0.3倍に下げると七本とも外側に出て、いちばん短い棒は6pxです。文字幅はmeasureTextがその瞬間に使っているフォントで測るので、Webフォントを使う場合はフォントが読み込まれるまでのあいだ判定が変わることがあります(このデモは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以降は目標±0.5の内側に留まります(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を持ち、Tabキーでフォーカスを移せます。←/→は棒を移り(02は今の順位の順、07はマスの番号順)、EnterとSpaceはその棒を固定して同じ棒ならもう一度押すと外し、Escapeは固定を外します。三つとも既定の動作を止めたあと、右上の状態の行が「項目 C · 87」、04なら「項目 D · 15,940 · 外側 4.0:1」のように名前と値を書きます。
押した状態を伝える切り替えボタン
aria-pressed="false"から始まるトグルは七つの盤にあり、02の「一時停止」、03の「系列 3 を隠す」、04の「棒を短くする」、06の「レンズを切る」、07の「光を止める」、08の「一様乱数ひとつで」、09の「すべて反転」です。01と05は、押すたびに同じ動作をするボタンが二つずつ並んでいます。
指した棒と固定した棒の枠線
ポインターを乗せただけの棒は[4, 3]間隔の点線、押して固定した棒は実線の枠で囲むので、色を見分けられなくても状態が分かれます。キャンバスとボタンのキーボードフォーカスの枠はstyle.cssの:focus-visibleが描きます。06はpointermoveのリスナーを二つ持ち、ひとつは九つの盤に共通する棒の指し示し、もうひとつはレンズの焦点合わせで、pointerleaveでは固定がないまま盤の外へ出るとlensGoalが0、選択が−1に戻ってレンズが閉じます。
動きを減らす設定にした画面
九つの盤はどれもmatchMedia('(prefers-reduced-motion: reduce)')にchangeのリスナーを付けているので、ページを開いたままprefers-reduced-motionの設定を変えてもすぐに従います。減らす側ならアニメーションのループを止めて目標の状態へそのまま移して一度だけ描き、その状態でもキー・ボタン・クリックは結果の画面をすぐに変えます。ただし02はこのとき最初の画面が最後のキーフレームなので、一時停止ボタンだけは画面をそのままにして押された状態を切り替え、style.cssの同じメディアクエリはボタンの背景色の遷移まで止めます。
積み上げ 棒グラフ アニメーションはCSSだけでどこまでできて、どこからキャンバスが要るのか?
決まった高さまで棒を数本伸ばすくらいなら、CSSの高さの遷移だけでも足ります。ただ、この記事の九つの盤はCSSではなくキャンバスとスクリプトで作っていて、その理由は盤ごとに値を読んで次の画面を決める計算が入るからです。02は16msごとに値を並べ替えて順位を付け直し、04は文字幅と輝度を測ってラベルの位置を選び、05は最大値から目盛りの間隔を求めます。03の層も、隠した系列の重みを0へ縮めるあいだ上の層の床を累積和で毎ステップ計算し直すので、高さを固定値で書いておくCSSの遷移では追いつきません。06の境界の配り直し、07の面の法線と光の内積、08の重力の積分も毎ステップ数を計算し直す必要があり、スクリプトの受け持ちになります。棒はfillRectとパスの塗りで、数字の幅はmeasureTextで、時間はrequestAnimationFrameで扱いました。
同じデータ可視化の流れで扇形を扱う記事は円グラフ アニメーション 9種、線と棒をSVGで引く記事はSVGグラフ 9種です。キャンバスのチャート全般はキャンバスのチャートアニメーション 9種とデータ可視化のチャート 9種、画素の計算で場面を埋める方向はキャンバスの特殊効果 9種へつながります。
棒グラフをレポート画面に載せる前のよくある質問 — ばね・自分の数字・読み上げ
横 棒グラフ アニメーションにも01のばねをそのまま使えますか?
使えます。springStepは高さ・速度・目標の三つの数しか扱わず向きを知らないので、04の棒の長さに付けても同じ行き過ぎが起きます。04は今の長さを目標へステップごとに12%ずつ寄せていて行き過ぎがなく、ステップごとにその瞬間の長さlen[i]でラベルの内外を判定し直しています。ばねに替えると長さが揺れるあいだ判定もぶれることがあるので、判定にだけ目標の長さを渡すほうが落ち着きます(状態の行を書くreport()は、もともと目標の長さで判定しています)。
自分の数字に替えるにはどこを直せばいいですか?
01はseededValues(seed, BAR_N, 30, 94)で作った目標の配列を、自分の値8個に替えれば動きます。01の軸は0〜100に固定なので、100を超える値ならAXIS_MAXも一緒に直すか、05のniceStep・niceMaxを持ってきて軸の端を値から求めます。09は軸と基準線を値から計算するため、setsの配列を替えるだけで0の位置がついてきます。
スクリーンリーダーの利用者には棒の値がどう伝わりますか?
キャンバスの中の棒はひとつずつ読み上げられる要素ではないので、このデモはキャンバスにaria-labelを一行付け、選んだ棒の値は状態の行で伝えます。状態の行のaria-liveは自動の実演中はoffにして実演が切り替わるたびに読まれないようにし、人がキー・ボタン・ポインターを初めて操作した瞬間にpoliteへ変わります。レポートのように数字全体を一度に確かめる画面なら、グラフの横か下に同じ値を入れたtableを一緒に置くほうがよく、このデモにはその表は入っていません。








