04A Timing Chart That Spaces Each Page Differently
The height of a falling ball over twelve pages, frameY(i) = (i/11) to the power EASE_POW, is dotted on a vertical line, and spacing between neighboring pages is shown as bars. At an exponent of 2.2 the first gap is 0.0051 and the last 0.1892, 37 times wider, so the fall seems to speed up; the equal panel (exponent 1) holds 0.0909 everywhere, and at 0.5 the pattern flips to 0.3015 first and 0.0465 last.
Best forThe cheapest way to change the weight of a motion with the same page count
EASE_POWspacingframeY

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Flipbook stage: left and right arrow keys or Space flip pages, Enter pauses, N steps once, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="The height of a falling ball over twelve pages is dotted on a vertical line, and the gap between neighbouring pages is shown as bars beside it. The left panel has an exponent of 2.2, so the gaps widen toward the bottom, and the right panel has all gaps equal. When the exponent switches to 0.5, the left panel gaps flip to widening toward the top"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Flipbook demo</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
<button class="vj__step" data-role="step" type="button">Step</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 쪽 넘기기·모서리 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 팔레트 — 면은 연한 색, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [172, 208, 246];
const BALL = [246, 184, 200];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b} 자리에 값을 끼운다 — 화면 문구는 늘 이 틀 한 줄로 두고 로케일 사전이 통째로 바꾼다
function fill(s, a, b) { return s.replace('{a}', a).replace('{b}', b === undefined ? '' : b); }
function r1(v) { return String(Math.round(v * 10) / 10); }
// 읽어 주는 문장은 사람이 직접 하거나 멈춘 때만 숫자를 싣는다 — 자동으로 흐르는 동안은 한 줄이 그대로여서 스크린리더가 매 프레임 읽지 않는다
function calm() { return !(paused || manual || still); }
const FRAME_COUNT = 12;
const FLOOR_Y = 0.8;
const HOP_H = 0.58;
function makePages(n) {
const out = [];
for (let i = 0; i < n; i += 1) {
// 두 번 튀므로 한 번 튀는 사이는 반 바퀴 — b 는 한 번 튀는 안의 위치 0~1, h 는 높이(포물선)
const b = (i * 2 / n) % 1;
const h = 4 * b * (1 - b);
const sy = 0.8 + 0.2 * Math.min(1, h * 3);
out.push({ x: 0.14 + 0.72 * (i / (n - 1)), y: FLOOR_Y - h * HOP_H, sx: 2 - sy, sy: sy });
}
return out;
}
const PAGES = makePages(FRAME_COUNT);
// 한 쪽의 종이 — 흰 바탕과 바닥 선
function paperBox(R, a, edge) {
box(R.x, R.y, R.w, R.h, 5, PAPER, a);
outline(R.x, R.y, R.w, R.h, 5, 1.5, edge, 0.9 * a);
const fy = R.y + R.h * (FLOOR_Y + 0.085);
line(R.x + R.w * 0.06, fy, R.x + R.w * 0.94, fy, 1.5, mix(PAPER, INK2, 0.35), a);
}
// 쪽 안의 공 한 개 — 눌림은 바닥에 닿은 쪽에서 가로로 퍼진다
function pageBall(R, p, c, a, edge, shine) {
if (a <= 0.01) {
return;
}
const rr = R.w * 0.075;
const cx = R.x + R.w * p.x;
const cy = R.y + R.h * p.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(p.sx, p.sy);
ctx.beginPath();
ctx.arc(0, 0, rr, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
ctx.lineWidth = 1.4 / Math.max(p.sx, p.sy);
ctx.strokeStyle = rgba(edge, 0.85 * a);
ctx.stroke();
if (shine) {
ctx.beginPath();
ctx.arc(-rr * 0.3, -rr * 0.34, rr * 0.3, 0, TAU);
ctx.fillStyle = rgba([255, 255, 255], 0.45 * a);
ctx.fill();
}
ctx.restore();
}
// 한 쪽을 통째로 — 종이·공·쪽 번호
function drawPage(R, i, a, label) {
paperBox(R, a, INK2);
pageBall(R, PAGES[i], BALL, a, INK, true);
if (label) {
text(String(i + 1), R.x + R.w - 8, R.y + 10, INK2, a, 9, 700, 'right');
}
}
// 정수부 — 쪽 번호는 늘 이 함수를 지난다(소수 쪽 번호가 그려지지 않게)
function wrapIdx(n, count) { return ((Math.floor(n + 1e-9) % count) + count) % count; }
// 쪽 번호 띠 — 작은 칸 count 개, 지금 쪽이 밝다
function thumbs(R, count, cols, cur) {
const rows = Math.ceil(count / cols);
const gap = 4;
const cw = (R.w - gap * (cols - 1)) / cols;
const ch = (R.h - gap * (rows - 1)) / rows;
for (let i = 0; i < count; i += 1) {
const cell = { x: R.x + (i % cols) * (cw + gap), y: R.y + Math.floor(i / cols) * (ch + gap), w: cw, h: ch };
const on = i === cur;
box(cell.x, cell.y, cell.w, cell.h, 3, on ? TILES[2] : PAPER, on ? 1 : 0.9);
outline(cell.x, cell.y, cell.w, cell.h, 3, on ? 2 : 1, on ? INK : mix(PAPER, INK2, 0.4), 1);
const p = PAGES[i % PAGES.length];
disc(cell.x + cell.w * p.x, cell.y + cell.h * p.y, Math.min(cell.w, cell.h) * 0.13, mix(BALL, INK, 0.1), 1);
}
}
const SEG = 8;
const PERSP = 0.0022;
function edgeProject(spineX, a, len, bendRad) {
const out = [{ x: spineX, z: 0, s: 1 }];
let x = spineX;
let z = 0;
const dl = len / SEG;
for (let k = 1; k <= SEG; k += 1) {
const ph = a - bendRad * Math.sin(a) * (k / SEG);
x += dl * Math.cos(ph);
z += dl * Math.sin(ph);
out.push({ x: x, z: z, s: 1 / (1 - z * PERSP) });
}
return out;
}
// 투영한 쪽 한 장을 윗변·아랫변을 따라 다각형으로 칠한다. 칠한 쪽의 평균 깊이를 돌려준다(그리는 순서 정렬용)
function flapPath(pts, cy, hp) {
ctx.beginPath();
ctx.moveTo(pts[0].x, cy - hp / 2 * pts[0].s);
for (let k = 1; k < pts.length; k += 1) {
ctx.lineTo(pts[k].x, cy - hp / 2 * pts[k].s);
}
for (let k = pts.length - 1; k >= 0; k -= 1) {
ctx.lineTo(pts[k].x, cy + hp / 2 * pts[k].s);
}
ctx.closePath();
}
function drawFlap(pts, cy, hp, c, a) {
// 모서리가 앞을 향할수록(옆으로 누울수록) 밝고, 책등 쪽으로 서서 얇아질수록 어둡다
const span = Math.abs(pts[pts.length - 1].x - pts[0].x);
const lit = clamp(span / (pts.length > 1 ? Math.max(1, Math.abs(pts[0].x - pts[1].x) * SEG) : 1), 0, 1);
flapPath(pts, cy, hp);
ctx.fillStyle = rgba(mix(c, [120, 110, 160], 0.4 * (1 - lit)), a);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, 0.85 * a);
ctx.stroke();
}
function meanZ(pts) {
let s = 0;
for (const p of pts) {
s += p.z;
}
return s / pts.length;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 판 둘레로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 공 한 개 — 면, 왼쪽 위 하이라이트, 옅은 윤곽
function orb(x, y, r, c, a) {
if (r <= 0.3 || a <= 0) {
return;
}
disc(x, y, r, c, a);
if (r > 5) {
disc(x - r * 0.28, y - r * 0.32, r * 0.42, [255, 255, 255], 0.3 * a);
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(mix(c, INK, 0.28), 0.55 * a);
ctx.stroke();
}
function line(x0, y0, x1, y1, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.3 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 장면이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 처음 판의 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 애니메이션 없이 곧바로 끝 상태로 — 시간을 3초 흘려 움직임을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// Step — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 쪽 넘기기 — 사람이 손대면 시간표는 멈추고 쪽만 바뀐다. 멈춘 채·움직임 줄이기에서도 한 번의 입력이 화면에 바로 반영되게 한다
function navPage(d) {
takeOver();
onNav(d);
if (still || paused) {
settleStill();
}
showNow();
}
// 키보드 — 무대에 포커스가 있을 때만. 왼쪽·위 = Prev 쪽, 오른쪽·아래·Space = 다음 쪽, Enter·P = Pause, N = Step, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
navPage(-1);
}
else if (k === 'ArrowRight' || k === 'ArrowDown' || k === ' ') {
e.preventDefault();
navPage(1);
}
else if (k === 'Enter' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 다음 쪽(06 은 모서리 끌기라 따로 받는다)
pad.addEventListener('click', function () {
if (!CLICK_NAV) {
return;
}
navPage(1);
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 04 — 열두 쪽의 점 높이 frameY(i) = (i / 11) 의 EASE_POW 제곱. 간격 spacing(i) 은 이웃 쪽 높이 차이이고 지수가 1 이면 Equal spacing, 크면 위가 촘촘하고 아래가 성기다
const EASE_POW = 2.2;
const SLOW_POW = 0.5;
const TRACK_HZ = 5;
const LAP_S = 9.6;
const START_S = 2.6;
const REST_S = 3.8;
const CLICK_NAV = true;
let phase = 0;
let powNow = EASE_POW;
let powTgt = EASE_POW;
let pageIndex = 0;
function build() {
phase = 0;
powNow = EASE_POW;
powTgt = EASE_POW;
pageIndex = 0;
script.push({ t: 4.6, f: function () { powTgt = SLOW_POW; } });
script.push({ t: 7.6, f: function () { powTgt = EASE_POW; } });
}
function update(dt) {
if (dt <= 0) {
return;
}
powNow += (powTgt - powNow) * (1 - Math.exp(-5 * dt));
if (!manual) {
phase += TRACK_HZ * dt;
}
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
function frameY(i, p) { return Math.pow(i / (FRAME_COUNT - 1), p); }
function spacing(i, p) { return i <= 0 ? 0 : frameY(i, p) - frameY(i - 1, p); }
// 한 판 — 왼쪽 줄은 열두 쪽 점의 높이, 오른쪽 막대는 그 쪽과 Prev 쪽 사이 간격
function drawTrack(R, p) {
const top = R.y + 16;
const bot = R.y + R.h - 16;
const tx = R.x + 34;
let gmax = 0;
for (let i = 1; i < FRAME_COUNT; i += 1) {
gmax = Math.max(gmax, spacing(i, p));
}
line(tx, top, tx, bot, 1.5, INK2, 0.35);
for (let i = 0; i < FRAME_COUNT; i += 1) {
const y = top + (bot - top) * frameY(i, p);
const on = i === pageIndex;
if (i > 0) {
const gw = 118 * spacing(i, p) / Math.max(gmax, 1e-6);
box(R.x + 70, y - 6, Math.max(gw, 2), 12, 6, on ? TILES[2] : TILES[4], on ? 1 : 0.85);
outline(R.x + 70, y - 6, Math.max(gw, 2), 12, 6, on ? 2 : 1, INK2, on ? 1 : 0.6);
}
disc(tx, y, on ? 10 : 3.4, on ? BALL : INK2, 1);
if (on) {
ring(tx, y, 10, 1.6, INK, 1);
}
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tag(fill('Spacing power {a}', r1(powNow)), PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag('Equal spacing', PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
drawTrack(PANEL_L, powNow);
drawTrack(PANEL_R, 1);
const g = spacing(Math.max(pageIndex, 1), powNow);
tag(fill('Gap {a}', r1(g * 100)), PANEL_L.x + PANEL_L.w - 56, PANEL_L.y + 22, PAPER, INK, 9, 'center');
}
function statusText() {
if (calm()) {
return powTgt > 1 ? 'Spacing widens downward (speeding up)' : 'Spacing widens upward (slowing down)';
}
return fill('Page {a} of {b}', String(pageIndex + 1), String(FRAME_COUNT));
}
function onNav(d) {
phase = Math.floor(phase + 1e-9) + d;
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// EaseSpacingChartTwelve - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./EaseSpacingChartTwelve.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function EaseSpacingChartTwelve() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 팔레트 — 면은 연한 색, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [172, 208, 246];
const BALL: RGB = [246, 184, 200];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
type Rect = { x: number; y: number; w: number; h: number };
// 문구 틀의 {a}·{b} 자리에 값을 끼운다 — 화면 문구는 늘 이 틀 한 줄로 두고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string): string { return s.replace('{a}', a).replace('{b}', b === undefined ? '' : b); }
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
// 읽어 주는 문장은 사람이 직접 하거나 멈춘 때만 숫자를 싣는다 — 자동으로 흐르는 동안은 한 줄이 그대로여서 스크린리더가 매 프레임 읽지 않는다
function calm(): boolean { return !(paused || manual || still); }
// ── 쪽 그림 ───────────────────────────────────────────────
// 한 쪽에 그린 그림 = 공 하나의 (가로·세로 위치 0~1, 눌림). 12쪽이 한 번의 튀어 오름 두 번을 이룬다
type Pg = { x: number; y: number; sx: number; sy: number };
const FRAME_COUNT = 12;
const FLOOR_Y = 0.8;
const HOP_H = 0.58;
function makePages(n: number): Pg[] {
const out: Pg[] = [];
for (let i = 0; i < n; i += 1) {
// 두 번 튀므로 한 번 튀는 사이는 반 바퀴 — b 는 한 번 튀는 안의 위치 0~1, h 는 높이(포물선)
const b = (i * 2 / n) % 1;
const h = 4 * b * (1 - b);
const sy = 0.8 + 0.2 * Math.min(1, h * 3);
out.push({ x: 0.14 + 0.72 * (i / (n - 1)), y: FLOOR_Y - h * HOP_H, sx: 2 - sy, sy: sy });
}
return out;
}
const PAGES: Pg[] = makePages(FRAME_COUNT);
// 한 쪽의 종이 — 흰 바탕과 바닥 선
function paperBox(R: Rect, a: number, edge: RGB): void {
box(R.x, R.y, R.w, R.h, 5, PAPER, a);
outline(R.x, R.y, R.w, R.h, 5, 1.5, edge, 0.9 * a);
const fy = R.y + R.h * (FLOOR_Y + 0.085);
line(R.x + R.w * 0.06, fy, R.x + R.w * 0.94, fy, 1.5, mix(PAPER, INK2, 0.35), a);
}
// 쪽 안의 공 한 개 — 눌림은 바닥에 닿은 쪽에서 가로로 퍼진다
function pageBall(R: Rect, p: Pg, c: RGB, a: number, edge: RGB, shine: boolean): void {
if (a <= 0.01) { return; }
const rr = R.w * 0.075;
const cx = R.x + R.w * p.x;
const cy = R.y + R.h * p.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(p.sx, p.sy);
ctx.beginPath();
ctx.arc(0, 0, rr, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
ctx.lineWidth = 1.4 / Math.max(p.sx, p.sy);
ctx.strokeStyle = rgba(edge, 0.85 * a);
ctx.stroke();
if (shine) {
ctx.beginPath();
ctx.arc(-rr * 0.3, -rr * 0.34, rr * 0.3, 0, TAU);
ctx.fillStyle = rgba([255, 255, 255], 0.45 * a);
ctx.fill();
}
ctx.restore();
}
// 한 쪽을 통째로 — 종이·공·쪽 번호
function drawPage(R: Rect, i: number, a: number, label: boolean): void {
paperBox(R, a, INK2);
pageBall(R, PAGES[i], BALL, a, INK, true);
if (label) { text(String(i + 1), R.x + R.w - 8, R.y + 10, INK2, a, 9, 700, 'right'); }
}
// 정수부 — 쪽 번호는 늘 이 함수를 지난다(소수 쪽 번호가 그려지지 않게)
function wrapIdx(n: number, count: number): number { return ((Math.floor(n + 1e-9) % count) + count) % count; }
// 쪽 번호 띠 — 작은 칸 count 개, 지금 쪽이 밝다
function thumbs(R: Rect, count: number, cols: number, cur: number): void {
const rows = Math.ceil(count / cols);
const gap = 4;
const cw = (R.w - gap * (cols - 1)) / cols;
const ch = (R.h - gap * (rows - 1)) / rows;
for (let i = 0; i < count; i += 1) {
const cell: Rect = { x: R.x + (i % cols) * (cw + gap), y: R.y + Math.floor(i / cols) * (ch + gap), w: cw, h: ch };
const on = i === cur;
box(cell.x, cell.y, cell.w, cell.h, 3, on ? TILES[2] : PAPER, on ? 1 : 0.9);
outline(cell.x, cell.y, cell.w, cell.h, 3, on ? 2 : 1, on ? INK : mix(PAPER, INK2, 0.4), 1);
const p = PAGES[i % PAGES.length];
disc(cell.x + cell.w * p.x, cell.y + cell.h * p.y, Math.min(cell.w, cell.h) * 0.13, mix(BALL, INK, 0.1), 1);
}
}
// ── 책등 투영 ─────────────────────────────────────────────
// 책등(세로 선) 에서 시작하는 쪽 한 장을 SEG 조각으로 잇는다. 쪽 각도 a 는 0 이 오른쪽에 누운 때, π 가 왼쪽에 누운 때.
// 조각 k 의 방향은 a 에서 끝쪽일수록 bendRad × sin(a) 만큼 뒤처진다(책등에 묶인 안쪽이 먼저 돌고 가장자리가 따라온다).
// x 는 방향의 코사인, z(보는 사람 쪽 깊이)는 사인을 쌓은 값이고, 가까울수록(z 가 클수록) 위아래가 PERSP 만큼 커진다
type Pt = { x: number; z: number; s: number };
const SEG = 8;
const PERSP = 0.0022;
function edgeProject(spineX: number, a: number, len: number, bendRad: number): Pt[] {
const out: Pt[] = [{ x: spineX, z: 0, s: 1 }];
let x = spineX;
let z = 0;
const dl = len / SEG;
for (let k = 1; k <= SEG; k += 1) {
const ph = a - bendRad * Math.sin(a) * (k / SEG);
x += dl * Math.cos(ph);
z += dl * Math.sin(ph);
out.push({ x: x, z: z, s: 1 / (1 - z * PERSP) });
}
return out;
}
// 투영한 쪽 한 장을 윗변·아랫변을 따라 다각형으로 칠한다. 칠한 쪽의 평균 깊이를 돌려준다(그리는 순서 정렬용)
function flapPath(pts: Pt[], cy: number, hp: number): void {
ctx.beginPath();
ctx.moveTo(pts[0].x, cy - hp / 2 * pts[0].s);
for (let k = 1; k < pts.length; k += 1) { ctx.lineTo(pts[k].x, cy - hp / 2 * pts[k].s); }
for (let k = pts.length - 1; k >= 0; k -= 1) { ctx.lineTo(pts[k].x, cy + hp / 2 * pts[k].s); }
ctx.closePath();
}
function drawFlap(pts: Pt[], cy: number, hp: number, c: RGB, a: number): void {
// 모서리가 앞을 향할수록(옆으로 누울수록) 밝고, 책등 쪽으로 서서 얇아질수록 어둡다
const span = Math.abs(pts[pts.length - 1].x - pts[0].x);
const lit = clamp(span / (pts.length > 1 ? Math.max(1, Math.abs(pts[0].x - pts[1].x) * SEG) : 1), 0, 1);
flapPath(pts, cy, hp);
ctx.fillStyle = rgba(mix(c, [120, 110, 160], 0.4 * (1 - lit)), a);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, 0.85 * a);
ctx.stroke();
}
function meanZ(pts: Pt[]): number {
let s = 0;
for (const p of pts) { s += p.z; }
return s / pts.length;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 판 둘레로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 공 한 개 — 면, 왼쪽 위 하이라이트, 옅은 윤곽
function orb(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.3 || a <= 0) { return; }
disc(x, y, r, c, a);
if (r > 5) { disc(x - r * 0.28, y - r * 0.32, r * 0.42, [255, 255, 255], 0.3 * a); }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(mix(c, INK, 0.28), 0.55 * a);
ctx.stroke();
}
function line(x0: number, y0: number, x1: number, y1: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.3 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 장면이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 처음 판의 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 애니메이션 없이 곧바로 끝 상태로 — 시간을 3초 흘려 움직임을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 쪽 넘기기 — 사람이 손대면 시간표는 멈추고 쪽만 바뀐다. 멈춘 채·움직임 줄이기에서도 한 번의 입력이 화면에 바로 반영되게 한다
function navPage(d: number): void {
takeOver();
onNav(d);
if (still || paused) { settleStill(); }
showNow();
}
// 키보드 — 무대에 포커스가 있을 때만. 왼쪽·위 = 앞 쪽, 오른쪽·아래·Space = 다음 쪽, Enter·P = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
navPage(-1);
} else if (k === 'ArrowRight' || k === 'ArrowDown' || k === ' ') {
e.preventDefault();
navPage(1);
} else if (k === 'Enter' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 다음 쪽(06 은 모서리 끌기라 따로 받는다)
pad.addEventListener('click', function (): void {
if (!CLICK_NAV) { return; }
navPage(1);
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 04 — 열두 쪽의 점 높이 frameY(i) = (i / 11) 의 EASE_POW 제곱. 간격 spacing(i) 은 이웃 쪽 높이 차이이고 지수가 1 이면 같은 간격, 크면 위가 촘촘하고 아래가 성기다
const EASE_POW = 2.2;
const SLOW_POW = 0.5;
const TRACK_HZ = 5;
const LAP_S = 9.6;
const START_S = 2.6;
const REST_S = 3.8;
const CLICK_NAV = true;
let phase = 0;
let powNow = EASE_POW;
let powTgt = EASE_POW;
let pageIndex = 0;
function build(): void {
phase = 0;
powNow = EASE_POW;
powTgt = EASE_POW;
pageIndex = 0;
script.push({ t: 4.6, f: function (): void { powTgt = SLOW_POW; } });
script.push({ t: 7.6, f: function (): void { powTgt = EASE_POW; } });
}
function update(dt: number): void {
if (dt <= 0) { return; }
powNow += (powTgt - powNow) * (1 - Math.exp(-5 * dt));
if (!manual) { phase += TRACK_HZ * dt; }
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
function frameY(i: number, p: number): number { return Math.pow(i / (FRAME_COUNT - 1), p); }
function spacing(i: number, p: number): number { return i <= 0 ? 0 : frameY(i, p) - frameY(i - 1, p); }
// 한 판 — 왼쪽 줄은 열두 쪽 점의 높이, 오른쪽 막대는 그 쪽과 앞 쪽 사이 간격
function drawTrack(R: Rect, p: number): void {
const top = R.y + 16;
const bot = R.y + R.h - 16;
const tx = R.x + 34;
let gmax = 0;
for (let i = 1; i < FRAME_COUNT; i += 1) { gmax = Math.max(gmax, spacing(i, p)); }
line(tx, top, tx, bot, 1.5, INK2, 0.35);
for (let i = 0; i < FRAME_COUNT; i += 1) {
const y = top + (bot - top) * frameY(i, p);
const on = i === pageIndex;
if (i > 0) {
const gw = 118 * spacing(i, p) / Math.max(gmax, 1e-6);
box(R.x + 70, y - 6, Math.max(gw, 2), 12, 6, on ? TILES[2] : TILES[4], on ? 1 : 0.85);
outline(R.x + 70, y - 6, Math.max(gw, 2), 12, 6, on ? 2 : 1, INK2, on ? 1 : 0.6);
}
disc(tx, y, on ? 10 : 3.4, on ? BALL : INK2, 1);
if (on) { ring(tx, y, 10, 1.6, INK, 1); }
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tag(fill('간격 지수 {a}', r1(powNow)), PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag('같은 간격', PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
drawTrack(PANEL_L, powNow);
drawTrack(PANEL_R, 1);
const g = spacing(Math.max(pageIndex, 1), powNow);
tag(fill('이 쪽의 간격 {a}', r1(g * 100)), PANEL_L.x + PANEL_L.w - 56, PANEL_L.y + 22, PAPER, INK, 9, 'center');
}
function statusText(): string {
if (calm()) { return powTgt > 1 ? '열두 쪽 간격 — 아래로 갈수록 성기다(가속)' : '열두 쪽 간격 — 위로 갈수록 성기다(감속)'; }
return fill('{b}쪽 중 {a}쪽', String(pageIndex + 1), String(FRAME_COUNT));
}
function onNav(d: number): void {
phase = Math.floor(phase + 1e-9) + d;
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'플립북 시연 무대 — 왼쪽·오른쪽 방향키와 Space 로 쪽 넘기기, Enter 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'열두 쪽에서 공이 떨어지는 높이를 세로 줄에 점으로 찍고, 이웃 쪽 사이 간격을 옆의 막대로 보인다. 왼쪽 판은 지수 2.2 라 아래로 갈수록 간격이 성기고 오른쪽 판은 모든 간격이 같다. 지수가 0.5 로 바뀌면 왼쪽 판의 간격이 위로 갈수록 성긴 모양으로 뒤집힌다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'플립북 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 A Timing Chart That Spaces Each Page Differently — Measured area 25.848% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Flipbook Animation: 9 Canvas Page Flip Scenes
The post this demo lives in
Flipbook Animation: 9 Canvas Page Flip ScenesHow does a flipbook make motion from a few drawings? 02 overlays faint neighboring pages, 05 measures where the flip rat See all demos in this post
If you like this effect - similar demos
Transitions03 Badge count-bump toggle — each time a notification count value rises, that display's size value grows once then settles backCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions01 Default cross-fade9 CSS Page Transitions — No Library, Pure CSS
Transitions01 Full-width mega panel that opens on hover9 mega menu ui patterns, link list to site map