03A Fan of Pages Bending Around the Spine
Nine pages turn around the spine (spineX) from right to left. edgeProject joins each page cross-section into 8 pieces, lets every piece trail the page angle by BEND_K x 2.2 x sin(angle) more toward the tip, and enlarges the near pages above and below to project them. The left panel (no bend) stands flat and in the right panel the edge follows behind in a bending fan.
Best forA scene where a book bends in the hand and reads as depth rather than a flat picture
BEND_KspineXedgeProject

<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="Nine pages turn around the spine one after another from right to left and then come back. In the left panel the pages stand flat as they turn, and in the right panel the edges trail behind the spine-bound inner part in a bending fan, growing taller above and below as they come closer"></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 });
// 03 — 책등(spineX)에서 쪽 아홉 장이 차례로 넘어간다. 왼쪽 판은 휨 0, 오른쪽 판은 BEND_K 만큼 가장자리가 뒤처진다
const BEND_K = 0.3;
const BEND_SPAN = 2.2;
const FAN_N = 9;
const FLIP_S = 1.3;
const STAGGER_S = 0.16;
const PAGE_LEN = 98;
const PAGE_HT = 132;
const LAP_S = 9.0;
const START_S = 1.45;
const REST_S = 3.9;
const CLICK_NAV = true;
let leaves = [];
function build() {
leaves = [];
for (let j = 0; j < FAN_N; j += 1) {
leaves.push({ q: 0, want: 0, delay: 0 });
}
script.push({ t: 0.4, f: function () { flipAll(1); } });
script.push({ t: 4.9, f: function () { flipAll(0); } });
}
// 모든 쪽을 차례로 — 넘길 때는 맨 위 쪽부터, 되돌릴 때는 맨 아래 쪽부터
function flipAll(to) {
for (let j = 0; j < FAN_N; j += 1) {
leaves[j].want = to;
leaves[j].delay = (to === 1 ? j : FAN_N - 1 - j) * STAGGER_S;
}
}
function update(dt) {
if (dt <= 0) {
return;
}
for (const lf of leaves) {
if (lf.delay > 0) {
lf.delay = Math.max(0, lf.delay - dt);
continue;
}
const step = dt / FLIP_S;
lf.q = lf.want > lf.q ? Math.min(lf.want, lf.q + step) : Math.max(lf.want, lf.q - step);
if (still) {
lf.q = lf.want;
}
}
}
function flipped() {
let n = 0;
for (const lf of leaves) {
if (lf.want === 1 && lf.q >= 0.999) {
n += 1;
}
}
return n;
}
// 한 판 — 판 가운데에 책등을 세우고 쪽을 깊이 순서로 칠한다. 오른쪽에 누운 쪽은 j 가 큰 것부터, 왼쪽에 누운 쪽은 작은 것부터 쌓는다
function drawFan(R, bend) {
const spineX = R.x + R.w / 2;
const cy = R.y + R.h / 2 + 4;
const items = [];
for (let j = 0; j < FAN_N; j += 1) {
const a = Math.PI * ease(leaves[j].q);
const pts = edgeProject(spineX, a, PAGE_LEN, bend * BEND_SPAN);
items.push({ j: j, pts: pts, key: meanZ(pts) * 100 + (a < Math.PI / 2 ? -j : j) * 0.01 });
}
items.sort(function (p, q) { return p.key - q.key; });
for (const it of items) {
drawFlap(it.pts, cy, PAGE_HT, TILES[it.j % 8], 1);
}
line(spineX, cy - PAGE_HT / 2 - 4, spineX, cy + PAGE_HT / 2 + 4, 3, INK2, 1);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tag('No spine bend', PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(fill('Spine bend {a}', r1(BEND_K)), PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
drawFan(PANEL_L, 0);
drawFan(PANEL_R, BEND_K);
}
function statusText() {
if (calm()) {
return fill('{a} pages turn around the spine', String(FAN_N));
}
return fill('{a} of {b} pages turned', String(flipped()), String(FAN_N));
}
// 한 장 넘기기 — 앞으로는 아직 안 넘긴 맨 위 쪽, 뒤로는 turned Middle 맨 위
function onNav(d) {
if (d > 0) {
for (let j = 0; j < FAN_N; j += 1) {
if (leaves[j].want === 0) {
leaves[j].want = 1;
leaves[j].delay = 0;
return;
}
}
}
else {
for (let j = FAN_N - 1; j >= 0; j -= 1) {
if (leaves[j].want === 1) {
leaves[j].want = 0;
leaves[j].delay = 0;
return;
}
}
}
}
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SpineBendFanProjection - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SpineBendFanProjection.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SpineBendFanProjection() {
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 });
// 03 — 책등(spineX)에서 쪽 아홉 장이 차례로 넘어간다. 왼쪽 판은 휨 0, 오른쪽 판은 BEND_K 만큼 가장자리가 뒤처진다
const BEND_K = 0.3;
const BEND_SPAN = 2.2;
const FAN_N = 9;
const FLIP_S = 1.3;
const STAGGER_S = 0.16;
const PAGE_LEN = 98;
const PAGE_HT = 132;
const LAP_S = 9.0;
const START_S = 1.45;
const REST_S = 3.9;
const CLICK_NAV = true;
type Leaf = { q: number; want: number; delay: number };
let leaves: Leaf[] = [];
function build(): void {
leaves = [];
for (let j = 0; j < FAN_N; j += 1) { leaves.push({ q: 0, want: 0, delay: 0 }); }
script.push({ t: 0.4, f: function (): void { flipAll(1); } });
script.push({ t: 4.9, f: function (): void { flipAll(0); } });
}
// 모든 쪽을 차례로 — 넘길 때는 맨 위 쪽부터, 되돌릴 때는 맨 아래 쪽부터
function flipAll(to: number): void {
for (let j = 0; j < FAN_N; j += 1) {
leaves[j].want = to;
leaves[j].delay = (to === 1 ? j : FAN_N - 1 - j) * STAGGER_S;
}
}
function update(dt: number): void {
if (dt <= 0) { return; }
for (const lf of leaves) {
if (lf.delay > 0) { lf.delay = Math.max(0, lf.delay - dt); continue; }
const step = dt / FLIP_S;
lf.q = lf.want > lf.q ? Math.min(lf.want, lf.q + step) : Math.max(lf.want, lf.q - step);
if (still) { lf.q = lf.want; }
}
}
function flipped(): number {
let n = 0;
for (const lf of leaves) { if (lf.want === 1 && lf.q >= 0.999) { n += 1; } }
return n;
}
// 한 판 — 판 가운데에 책등을 세우고 쪽을 깊이 순서로 칠한다. 오른쪽에 누운 쪽은 j 가 큰 것부터, 왼쪽에 누운 쪽은 작은 것부터 쌓는다
function drawFan(R: Rect, bend: number): void {
const spineX = R.x + R.w / 2;
const cy = R.y + R.h / 2 + 4;
const items: { j: number; pts: Pt[]; key: number }[] = [];
for (let j = 0; j < FAN_N; j += 1) {
const a = Math.PI * ease(leaves[j].q);
const pts = edgeProject(spineX, a, PAGE_LEN, bend * BEND_SPAN);
items.push({ j: j, pts: pts, key: meanZ(pts) * 100 + (a < Math.PI / 2 ? -j : j) * 0.01 });
}
items.sort(function (p: { key: number }, q: { key: number }): number { return p.key - q.key; });
for (const it of items) { drawFlap(it.pts, cy, PAGE_HT, TILES[it.j % 8], 1); }
line(spineX, cy - PAGE_HT / 2 - 4, spineX, cy + PAGE_HT / 2 + 4, 3, INK2, 1);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tag('책등 휨 없음', PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(fill('책등 휨 {a}', r1(BEND_K)), PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
drawFan(PANEL_L, 0);
drawFan(PANEL_R, BEND_K);
}
function statusText(): string {
if (calm()) { return fill('쪽 {a}장이 책등을 돌아 넘어간다', String(FAN_N)); }
return fill('{b}쪽 중 {a}쪽 넘김', String(flipped()), String(FAN_N));
}
// 한 장 넘기기 — 앞으로는 아직 안 넘긴 맨 위 쪽, 뒤로는 넘긴 쪽 가운데 맨 위
function onNav(d: number): void {
if (d > 0) {
for (let j = 0; j < FAN_N; j += 1) { if (leaves[j].want === 0) { leaves[j].want = 1; leaves[j].delay = 0; return; } }
} else {
for (let j = FAN_N - 1; j >= 0; j -= 1) { if (leaves[j].want === 1) { leaves[j].want = 0; leaves[j].delay = 0; return; } }
}
}
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={'쪽 아홉 장이 책등을 돌아 오른쪽에서 왼쪽으로 차례로 넘어갔다가 되돌아온다. 왼쪽 판은 쪽이 판판하게 서서 넘어가고, 오른쪽 판은 가장자리가 책등에 묶인 안쪽보다 늦게 따라와 휘는 부채꼴이며 가까워질수록 위아래가 커진다'}></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>
);
}03 A Fan of Pages Bending Around the Spine — Measured area 32.601% · moving frames 12/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
Transitions08 Circle reveal from the icon9 CSS Hamburger Menu Animations, No Library
Transitions05 Sticky sub-nav that announces the current section9 mega menu ui patterns, link list to site map
Transitions02 Backdrop scale-back — the page behind shrinks in proportion to how far the panel has openedCart Drawer UI Patterns: 9 Motion Techniques