02An Onion Skin That Shows the Neighboring Pages
Over the current page the ball of each previous page is tinted blue and the ball of each next page red, with alpha starting at GHOST_ALPHA (0.25) and halving with each step of distance out to ghostSpan (3) steps (0.25, 0.125, 0.0625). You can read where the dot came from and where it goes on one screen, and the overlay is switched on and off beside a plain page.
Best forThe aid a drawer uses to line a new page up with the pages around it
GHOST_ALPHAghostSpantint

<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 same twelve-page flipbook is shown in two panels side by side. The left panel overlays the previous pages ball in a blue tint and the next pages ball in a red tint, fading by half with each step of distance, and joins the dots path with a thin line. Turning the overlay off leaves only the current page, and the right panel always shows no overlay"></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 });
// 02 — 지금 쪽 위에 Prev 쪽은 푸른 기, Next 쪽은 붉은 기로 겹쳐 그린다. 알파는 GHOST_ALPHA 에서 거리 한 칸마다 반으로 줄어 ghostSpan 칸까지
const GHOST_ALPHA = 0.25;
const ghostSpan = 3;
const tint = [[96, 150, 240], [240, 112, 148]];
const STEP_HZ = 2.4;
const LAP_S = 9.6;
const START_S = 3.0;
const REST_S = 4.2;
const CLICK_NAV = true;
const BOOK_L = { x: 36, y: 92, w: 170, h: 170 };
const BOOK_R = { x: 270, y: 92, w: 170, h: 170 };
let phase = 0;
let onion = true;
let gv = 1;
let pageIndex = 0;
function build() {
phase = 0;
onion = true;
gv = 1;
pageIndex = 0;
script.push({ t: 4.8, f: function () { onion = false; } });
script.push({ t: 7.0, f: function () { onion = true; } });
}
function update(dt) {
if (dt <= 0) {
return;
}
gv += ((onion ? 1 : 0) - gv) * (1 - Math.exp(-9 * dt));
if (!manual) {
phase += STEP_HZ * dt;
}
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
// 겹침 — 거리 d(1 부터)의 쪽은 알파 GHOST_ALPHA × 0.5^(d-1). 한 바퀴 끝에서 처음으로 돌아가는 이음새는 겹치지 않는다
function ghosts(R, amount) {
for (let d = ghostSpan; d >= 1; d -= 1) {
const a = GHOST_ALPHA * Math.pow(0.5, d - 1) * amount;
for (let s = 0; s < 2; s += 1) {
const j = s === 0 ? pageIndex - d : pageIndex + d;
if (j < 0 || j >= FRAME_COUNT) {
continue;
}
pageBall(R, PAGES[j], tint[s], a, tint[s], false);
}
}
}
// 점이 지나온 길 — 모든 쪽의 점을 잇는 가는 선
function route(R, a) {
ctx.beginPath();
for (let i = 0; i < FRAME_COUNT; i += 1) {
const x = R.x + R.w * PAGES[i].x;
const y = R.y + R.h * PAGES[i].y;
if (i === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, a);
ctx.stroke();
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tag(onion ? 'Overlay on' : 'Overlay off', PANEL_L.x + PANEL_L.w / 2, 66, onion ? INK : PAPER, onion ? PAPER : INK, 10, 'center');
tag('No overlay', PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
paperBox(BOOK_L, 1, INK2);
route(BOOK_L, 0.28 * gv);
ghosts(BOOK_L, gv);
pageBall(BOOK_L, PAGES[pageIndex], BALL, 1, INK, true);
text(String(pageIndex + 1), BOOK_L.x + BOOK_L.w - 8, BOOK_L.y + 10, INK2, 1, 9, 700, 'right');
drawPage(BOOK_R, pageIndex, 1, true);
// 색 안내 — Prev 쪽·Next 쪽
disc(PANEL_L.x + 24, 276, 5, tint[0], 1);
text('Prev', PANEL_L.x + 34, 276, INK, 1, 10, 700, 'left');
disc(PANEL_L.x + 70, 276, 5, tint[1], 1);
text('Next', PANEL_L.x + 80, 276, INK, 1, 10, 700, 'left');
}
function statusText() {
if (calm()) {
return onion ? 'Onion skin on - blue before, red after' : 'Onion skin off - current page only';
}
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();
};
})();// OnionSkinNeighborGhost - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./OnionSkinNeighborGhost.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function OnionSkinNeighborGhost() {
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 });
// 02 — 지금 쪽 위에 앞 쪽은 푸른 기, 뒤 쪽은 붉은 기로 겹쳐 그린다. 알파는 GHOST_ALPHA 에서 거리 한 칸마다 반으로 줄어 ghostSpan 칸까지
const GHOST_ALPHA = 0.25;
const ghostSpan = 3;
const tint: RGB[] = [[96, 150, 240], [240, 112, 148]];
const STEP_HZ = 2.4;
const LAP_S = 9.6;
const START_S = 3.0;
const REST_S = 4.2;
const CLICK_NAV = true;
const BOOK_L: Rect = { x: 36, y: 92, w: 170, h: 170 };
const BOOK_R: Rect = { x: 270, y: 92, w: 170, h: 170 };
let phase = 0;
let onion = true;
let gv = 1;
let pageIndex = 0;
function build(): void {
phase = 0;
onion = true;
gv = 1;
pageIndex = 0;
script.push({ t: 4.8, f: function (): void { onion = false; } });
script.push({ t: 7.0, f: function (): void { onion = true; } });
}
function update(dt: number): void {
if (dt <= 0) { return; }
gv += ((onion ? 1 : 0) - gv) * (1 - Math.exp(-9 * dt));
if (!manual) { phase += STEP_HZ * dt; }
pageIndex = wrapIdx(phase, FRAME_COUNT);
}
// 겹침 — 거리 d(1 부터)의 쪽은 알파 GHOST_ALPHA × 0.5^(d-1). 한 바퀴 끝에서 처음으로 돌아가는 이음새는 겹치지 않는다
function ghosts(R: Rect, amount: number): void {
for (let d = ghostSpan; d >= 1; d -= 1) {
const a = GHOST_ALPHA * Math.pow(0.5, d - 1) * amount;
for (let s = 0; s < 2; s += 1) {
const j = s === 0 ? pageIndex - d : pageIndex + d;
if (j < 0 || j >= FRAME_COUNT) { continue; }
pageBall(R, PAGES[j], tint[s], a, tint[s], false);
}
}
}
// 점이 지나온 길 — 모든 쪽의 점을 잇는 가는 선
function route(R: Rect, a: number): void {
ctx.beginPath();
for (let i = 0; i < FRAME_COUNT; i += 1) {
const x = R.x + R.w * PAGES[i].x;
const y = R.y + R.h * PAGES[i].y;
if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, a);
ctx.stroke();
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tag(onion ? '겹침 켬' : '겹침 끔', PANEL_L.x + PANEL_L.w / 2, 66, onion ? INK : PAPER, onion ? PAPER : INK, 10, 'center');
tag('겹침 없는 판', PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
bed(PANEL_L);
bed(PANEL_R);
paperBox(BOOK_L, 1, INK2);
route(BOOK_L, 0.28 * gv);
ghosts(BOOK_L, gv);
pageBall(BOOK_L, PAGES[pageIndex], BALL, 1, INK, true);
text(String(pageIndex + 1), BOOK_L.x + BOOK_L.w - 8, BOOK_L.y + 10, INK2, 1, 9, 700, 'right');
drawPage(BOOK_R, pageIndex, 1, true);
// 색 안내 — 앞 쪽·뒤 쪽
disc(PANEL_L.x + 24, 276, 5, tint[0], 1);
text('앞', PANEL_L.x + 34, 276, INK, 1, 10, 700, 'left');
disc(PANEL_L.x + 70, 276, 5, tint[1], 1);
text('뒤', PANEL_L.x + 80, 276, INK, 1, 10, 700, 'left');
}
function statusText(): string {
if (calm()) { return onion ? '양파 껍질 켬 — 앞 쪽은 푸른 기, 뒤 쪽은 붉은 기' : '양파 껍질 끔 — 지금 쪽만 보인다'; }
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={'같은 열두 쪽 플립북을 두 판으로 나란히 보인다. 왼쪽 판은 지금 쪽 위에 앞 쪽의 공을 푸른 기로, 뒤 쪽의 공을 붉은 기로 거리마다 절반으로 옅게 겹쳐 그리고 점이 지나는 길을 가는 선으로 잇는다. 겹침을 끄면 지금 쪽만 남고 오른쪽 판은 늘 겹침 없이 보인다'}></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>
);
}02 An Onion Skin That Shows the Neighboring Pages — Measured area 7.299% · moving frames 7/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
Transitions05 Sticky sub-nav that announces the current section9 mega menu ui patterns, link list to site map
Transitions04 Elastic quantity bump — a quantity number that grows then eases back by the size of its changeCart Drawer UI Patterns: 9 Motion Techniques
Transitions02 Icon fill-swap toggle — when the selected tab changes, that spot's fill-amount value switches from low to fullCSS Bottom Navigation Bar: 9 Transition Patterns