pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. pdf 미리보기 구현을 이 아홉 갈래로 나눠보면 답이 보인다.
02 pdf-viewer-pointer-zoom-focal-point
03 pdf-viewer-search-match-highlight-jump
04 pdf-viewer-swipe-velocity-page-flip
05 pdf-viewer-byte-progress-load-bar
06 pdf-viewer-rotate-snap-control
07 pdf-viewer-keyboard-page-ease-scroll
08 pdf-viewer-download-progress-ring
09 pdf-viewer-hero-toolbar-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 본문 스크롤 비율로 현재 페이지를 계산하는 썸네일 레일 동기화
- 02 포인터 좌표를 기준으로 확대 중심을 계산하는 초점 줌
- 03 일치한 검색어 위치를 계산해 점프하는 검색 하이라이트
- 04 손을 뗄 때의 속도로 다음 장 여부를 계산하는 스와이프 플립
- 05 받은 바이트 수로 진행률을 계산하는 프로그레시브 로드 바
- 06 90도 단위로 각도를 맞추는 회전 스냅 컨트롤
- 07 남은 스크롤 거리를 계산해 부드럽게 이동하는 키보드 페이지 이동
- 08 받은 바이트 비율만큼 채워지는 다운로드 진행 링
- 09 여러 계산을 한 툴바에 모은 문서 뷰어 히어로 장면
pdf 미리보기 구현 9가지 — 스크롤과 확대를 계산으로 잇는 뷰어
아홉 항목은 전부 "지금 어디를 보고 있는가"를 하나의 숫자로 계산하고, 그 숫자로 다른 요소를 움직인다. 01·07·09 는 스크롤 위치를 페이지 번호로 바꾸고, 02·06 은 좌표와 각도를 화면 크기로 바꾸며, 04·05·08 은 속도와 바이트 수를 진행률로 바꾼다. 03 은 이 계산 축의 반대 방향이다 — 페이지 번호를 알고 스크롤 위치를 구한다. pdf 뷰어 ui 를 직접 만들 때 이 아홉 갈래 중 몇 개가 겹쳐 있는지 보면 뷰어가 왜 복잡해지는지 알 수 있다.
패턴별 코드
아래 코드는 계산이 몰려 있는 구간만 잘라 붙였고, 화면을 채우는 스타일·마크업 나머지는 데모 아래 압축 파일에 들어 있다. 페이지 내용은 전부 seedRand 로 만든 가짜 문서이고, 실제 PDF 파일을 읽지는 않는다.
01본문 스크롤 비율로 현재 페이지를 계산하는 썸네일 레일 동기화
각 페이지 요소가 뷰포트와 겹치는 비율을 매 프레임 계산해 가장 많이 보이는 페이지를 찾고, 그 인덱스가 바뀔 때만 썸네일 레일의 강조 칸과 배지 숫자를 갱신한다. 레일 자체는 강조된 칸이 가운데로 오도록 목표 위치를 향해 매 프레임 조금씩 따라가며, 사용자가 직접 조작하면 그 즉시 따라가는 비율이 1로 바뀐다. 01 항목은 누적면적 59.6%·강도 평균 39.5·23프레임 중 23프레임에서 움직였다.
<main class="stage">
<div class="pvt is-demo" id="pvtRoot" tabindex="0">
<div class="pvt__rail" id="pvtRail"><div class="pvt__track" id="pvtTrack"></div></div>
<div class="pvt__doc" id="pvtDoc"></div>
<p class="pvt__badge" id="pvtBadge">1 / 6</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
$doc-bg: $subject-cream;
$bar: rgba(23, 20, 26, .16);
$bar-strong: rgba(23, 20, 26, .32);
.pvt {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $doc-bg;
}
.pvt__rail {
flex: 0 0 auto;
height: 17%;
overflow: hidden;
background: $stage-ink;
padding: $sp-1 0;
box-sizing: border-box;
}
.pvt__track {
display: flex;
gap: $sp-2;
padding: 0 $sp-3;
height: 100%;
will-change: transform;
}
.pvt__thumb {
flex: 0 0 auto;
width: 15%;
min-width: 34px;
height: 100%;
border: 2px solid rgba(255, 255, 255, .18);
border-radius: $r-sm;
background: $doc-bg;
padding: $sp-1;
box-sizing: border-box;
cursor: pointer;
&--active { border-color: $subject-blue-light; }
}
.pvt__miniTitle { height: 3px; border-radius: $r-xs; background: $bar-strong; margin-bottom: 4px; }
.pvt__miniLine { height: 2px; border-radius: $r-xs; background: $bar; margin-top: 4px; }
.pvt__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.doc__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-2 $sp-3;
margin-bottom: $sp-2;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
}
.doc__title { height: 8px; border-radius: $r-xs; background: $bar-strong; margin-bottom: $sp-1; }
.doc__line { height: 5px; border-radius: $r-xs; background: $bar; margin-top: 4px; }
.doc__fig { height: 30px; border-radius: $r-sm; margin-top: $sp-1; background: rgba(47, 109, 246, .18); }
.pvt__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-size: 11px;
font-weight: 600;
pointer-events: none;
}
@media (max-width: 340px) {
.pvt__badge { font-size: 9px; padding: 4px $sp-2; }
.doc__page { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pvt__doc { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvt {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.pvt__rail {
flex: 0 0 auto;
height: 17%;
overflow: hidden;
background: #17141a;
padding: 4px 0;
box-sizing: border-box;
}
.pvt__track {
display: flex;
gap: 8px;
padding: 0 12px;
height: 100%;
will-change: transform;
}
.pvt__thumb {
flex: 0 0 auto;
width: 15%;
min-width: 34px;
height: 100%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: #fff7e6;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
}
.pvt__thumb--active {
border-color: #5b8cf8;
}
.pvt__miniTitle {
height: 3px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.pvt__miniLine {
height: 2px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
margin-top: 4px;
}
.pvt__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvt__doc::-webkit-scrollbar {
display: none;
}
.doc__page {
background: #fff;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
}
.doc__title {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.doc__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
margin-top: 4px;
}
.doc__fig {
height: 30px;
border-radius: 8px;
margin-top: 4px;
background: rgba(47, 109, 246, 0.18);
}
.pvt__badge {
position: absolute;
right: 8px;
bottom: 8px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-size: 11px;
font-weight: 600;
pointer-events: none;
}
@media (max-width: 340px) {
.pvt__badge {
font-size: 9px;
padding: 4px 8px;
}
.doc__page {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvt__doc {
scroll-behavior: auto;
}
}(function () {
// 기법: 각 페이지 요소가 문서 뷰포트와 겹치는 비율을 매 프레임 getBoundingClientRect 로 계산해
// 가장 많이 보이는 페이지를 찾는다(IntersectionObserver 대신 직접 계산). 그 페이지의 썸네일
// 위치를 레일 가운데로 lerp(관성 보간)로 이동시켜, 활성 칸이 부드럽게 따라온다.
var root = document.getElementById('pvtRoot');
var rail = document.getElementById('pvtRail');
var track = document.getElementById('pvtTrack');
var doc = document.getElementById('pvtDoc');
var badge = document.getElementById('pvtBadge');
var PAGE_COUNT = 6;
var RAIL_LERP = 0.18;
var LOOP_MS = 2000;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makePages(seed, n) {
var rnd = seedRand(seed), pages = [];
for (var i = 0; i < n; i += 1) {
var lineCount = 4 + Math.floor(rnd() * 3), lines = [];
for (var j = 0; j < lineCount; j += 1) lines.push(46 + rnd() * 44);
pages.push({ n: i + 1, titleW: 34 + rnd() * 26, lines: lines, hasFig: rnd() < 0.4 });
}
return pages;
}
var pages = makePages(430011, PAGE_COUNT);
function pageHTML(p, mini) {
var tcls = mini ? 'pvt__miniTitle' : 'doc__title';
var lcls = mini ? 'pvt__miniLine' : 'doc__line';
var html = '<div class="' + tcls + '" style="width:' + p.titleW + '%"></div>';
var lines = mini ? p.lines.slice(0, 2) : p.lines;
for (var i = 0; i < lines.length; i += 1) html += '<div class="' + lcls + '" style="width:' + lines[i] + '%"></div>';
if (!mini && p.hasFig) html += '<div class="doc__fig"></div>';
return html;
}
var docEls = [], thumbEls = [];
for (var i = 0; i < pages.length; i += 1) {
var page = document.createElement('div');
page.className = 'doc__page';
page.innerHTML = pageHTML(pages[i], false);
doc.appendChild(page);
docEls.push(page);
var thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'pvt__thumb';
thumb.setAttribute('aria-label', '페이지 ' + pages[i].n + '로 이동');
thumb.innerHTML = pageHTML(pages[i], true);
thumb.addEventListener('click', (function (idx) {
return function () { userActive = true; root.classList.remove('is-demo'); doc.scrollTop = docEls[idx].offsetTop - 6; };
})(i));
track.appendChild(thumb);
thumbEls.push(thumb);
}
var current = 0;
function visibleRatio(el, viewTop, viewH) {
var top = el.offsetTop, h = el.offsetHeight;
var lo = Math.max(top, viewTop), hi = Math.min(top + h, viewTop + viewH);
return Math.max(0, hi - lo) / h;
}
function computeCurrent() {
var viewTop = doc.scrollTop, viewH = doc.clientHeight, best = 0, bestR = -1;
for (var i = 0; i < docEls.length; i += 1) {
var r = visibleRatio(docEls[i], viewTop, viewH);
if (r > bestR) { bestR = r; best = i; }
}
if (best !== current) {
current = best;
for (var j = 0; j < thumbEls.length; j += 1) thumbEls[j].classList.toggle('pvt__thumb--active', j === current);
badge.textContent = (current + 1) + ' / ' + pages.length;
}
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
doc.addEventListener('scroll', computeCurrent);
doc.addEventListener('wheel', function () { markActive(); }, { passive: true });
doc.addEventListener('pointerdown', function () { markActive(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'PageDown' || e.key === 'PageUp') {
e.preventDefault(); markActive();
var next = e.key === 'ArrowDown' || e.key === 'PageDown' ? Math.min(current + 1, docEls.length - 1) : Math.max(current - 1, 0);
doc.scrollTop = docEls[next].offsetTop - 6;
computeCurrent();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
function railTarget() {
var t = thumbEls[current];
return (rail.clientWidth / 2) - (t.offsetLeft + t.offsetWidth / 2);
}
function clampRail(v) {
var min = Math.min(0, rail.clientWidth - track.scrollWidth);
return Math.min(0, Math.max(min, v));
}
function autoScroll(elapsed) {
var max = doc.scrollHeight - doc.clientHeight;
var phase = (elapsed % LOOP_MS) / LOOP_MS;
var wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
var eased = wave * wave * (3 - 2 * wave);
doc.scrollTop = max * eased;
computeCurrent();
}
var railOffset = 0, lastTs = null, elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; autoScroll(elapsed); }
var target = clampRail(railTarget());
railOffset += (target - railOffset) * (userActive ? 1 : RAIL_LERP);
track.style.transform = 'translateX(' + railOffset.toFixed(1) + 'px)';
requestAnimationFrame(tick);
}
computeCurrent();
thumbEls[0].classList.add('pvt__thumb--active');
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerThumbnailRailSyncScroll.module.scss";
type Page = { n: number; titleW: number; lines: number[]; hasFig: boolean };
type KeyEvt = { key: string; preventDefault(): void };
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makePages(seed: number, n: number): Page[] {
const rnd = seedRand(seed), pages: Page[] = [];
for (let i = 0; i < n; i += 1) {
const lineCount = 4 + Math.floor(rnd() * 3), lines: number[] = [];
for (let j = 0; j < lineCount; j += 1) lines.push(46 + rnd() * 44);
pages.push({ n: i + 1, titleW: 34 + rnd() * 26, lines, hasFig: rnd() < 0.4 });
}
return pages;
}
export default function PdfViewerThumbnailRailSyncScroll() {
const rootRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, rail = railRef.current, track = trackRef.current;
const doc = docRef.current, badge = badgeRef.current;
if (!root || !rail || !track || !doc || !badge) return;
const PAGE_COUNT = 6;
const RAIL_LERP = 0.18;
const LOOP_MS = 2000;
const pages = makePages(430011, PAGE_COUNT);
function pageHTML(p: Page, mini: boolean) {
const tcls = mini ? styles.miniTitle : styles.docTitle;
const lcls = mini ? styles.miniLine : styles.docLine;
let html = '<div class="' + tcls + '" style="width:' + p.titleW + '%"></div>';
const lines = mini ? p.lines.slice(0, 2) : p.lines;
for (let i = 0; i < lines.length; i += 1) html += '<div class="' + lcls + '" style="width:' + lines[i] + '%"></div>';
if (!mini && p.hasFig) html += '<div class="' + styles.docFig + '"></div>';
return html;
}
const docEls: HTMLDivElement[] = [], thumbEls: HTMLButtonElement[] = [];
let userActive = false;
for (let i = 0; i < pages.length; i += 1) {
const page = document.createElement("div");
page.className = styles.docPage;
page.innerHTML = pageHTML(pages[i], false);
doc.appendChild(page);
docEls.push(page);
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = styles.thumb;
thumb.setAttribute("aria-label", "페이지 " + pages[i].n + "로 이동");
thumb.innerHTML = pageHTML(pages[i], true);
thumb.addEventListener("click", (function (idx) {
return function () { userActive = true; root.classList.remove("is-demo"); doc.scrollTop = docEls[idx].offsetTop - 6; };
})(i));
track.appendChild(thumb);
thumbEls.push(thumb);
}
let current = 0;
function visibleRatio(el: HTMLElement, viewTop: number, viewH: number) {
const top = el.offsetTop, h = el.offsetHeight;
const lo = Math.max(top, viewTop), hi = Math.min(top + h, viewTop + viewH);
return Math.max(0, hi - lo) / h;
}
function computeCurrent() {
const viewTop = doc!.scrollTop, viewH = doc!.clientHeight;
let best = 0, bestR = -1;
for (let i = 0; i < docEls.length; i += 1) {
const r = visibleRatio(docEls[i], viewTop, viewH);
if (r > bestR) { bestR = r; best = i; }
}
if (best !== current) {
current = best;
for (let j = 0; j < thumbEls.length; j += 1) thumbEls[j].classList.toggle(styles.thumbActive, j === current);
badge!.textContent = (current + 1) + " / " + pages.length;
}
}
doc.addEventListener("scroll", computeCurrent);
const markActive = () => { userActive = true; root.classList.remove("is-demo"); };
doc.addEventListener("wheel", markActive, { passive: true });
doc.addEventListener("pointerdown", markActive);
const onKeydown = (e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "PageDown" || e.key === "PageUp") {
e.preventDefault(); markActive();
const next = (e.key === "ArrowDown" || e.key === "PageDown")
? Math.min(current + 1, docEls.length - 1) : Math.max(current - 1, 0);
doc!.scrollTop = docEls[next].offsetTop - 6;
computeCurrent();
}
};
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; }
function railTarget() {
const t = thumbEls[current];
return (rail!.clientWidth / 2) - (t.offsetLeft + t.offsetWidth / 2);
}
function clampRail(v: number) {
const min = Math.min(0, rail!.clientWidth - track!.scrollWidth);
return Math.min(0, Math.max(min, v));
}
function autoScroll(elapsed: number) {
const max = doc!.scrollHeight - doc!.clientHeight;
const phase = (elapsed % LOOP_MS) / LOOP_MS;
const wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
const eased = wave * wave * (3 - 2 * wave);
doc!.scrollTop = max * eased;
computeCurrent();
}
let railOffset = 0, lastTs: number | null = null, elapsed = 0, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; autoScroll(elapsed); }
const target = clampRail(railTarget());
railOffset += (target - railOffset) * (userActive ? 1 : RAIL_LERP);
track!.style.transform = "translateX(" + railOffset.toFixed(1) + "px)";
raf = requestAnimationFrame(tick);
}
computeCurrent();
thumbEls[0].classList.add(styles.thumbActive);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
doc.removeEventListener("scroll", computeCurrent);
doc.removeEventListener("wheel", markActive);
doc.removeEventListener("pointerdown", markActive);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvt} is-demo`} ref={rootRef} tabIndex={0}>
<div className={styles.rail} ref={railRef}><div className={styles.track} ref={trackRef} /></div>
<div className={styles.doc} ref={docRef} />
<p className={styles.badge} ref={badgeRef}>1 / 6</p>
</div>
</main>
);
}01 · 본문 스크롤 비율로 현재 페이지를 계산하는 썸네일 레일 동기화 — 측정 누적면적 59.608% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function visibleRatio(el, viewTop, viewH) {
var top = el.offsetTop, h = el.offsetHeight;
var lo = Math.max(top, viewTop), hi = Math.min(top + h, viewTop + viewH);
return Math.max(0, hi - lo) / h;
}
function computeCurrent() {
var viewTop = doc.scrollTop, viewH = doc.clientHeight, best = 0, bestR = -1;
for (var i = 0; i < docEls.length; i += 1) {
var r = visibleRatio(docEls[i], viewTop, viewH);
if (r > bestR) { bestR = r; best = i; }
}
if (best !== current) {
current = best;
for (var j = 0; j < thumbEls.length; j += 1) thumbEls[j].classList.toggle('pvt__thumb--active', j === current);
badge.textContent = (current + 1) + ' / ' + pages.length;
}
}
02포인터 좌표를 기준으로 확대 중심을 계산하는 초점 줌
휠이 일어난 지점의 화면 좌표를 뷰어 안쪽 요소 기준 비율(0~1)로 바꿔 두고, 그 비율을 transform-origin 으로 그대로 넘긴다. 확대 배율은 ZOOM_FOCAL_STEP_RATIO 의 거듭제곱으로 계산해 휠을 반복할수록 배율이 점점 커지되 상한을 넘지 않게 막는다. 02 항목은 누적면적 57.5%·강도 평균 93.4·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="pvz is-demo" id="pvzRoot" tabindex="0">
<div class="pvz__view" id="pvzView">
<div class="pvz__page" id="pvzPage"></div>
<div class="pvz__dot" id="pvzDot"></div>
</div>
<p class="pvz__label" id="pvzLabel">100%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pvz {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
outline: none;
}
.pvz__view {
position: absolute;
inset: 0;
overflow: hidden;
}
.pvz__page {
position: absolute;
left: 12%;
top: 10%;
width: 76%;
height: 80%;
background: $subject-cream;
border-radius: $r-sm;
padding: $sp-4;
box-sizing: border-box;
will-change: transform;
}
.pvz__title { height: 10px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvz__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvz__fig { height: 22%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvz__dot {
position: absolute;
width: 14px;
height: 14px;
margin-left: -7px;
margin-top: -7px;
border-radius: 50%;
background: rgba(255, 77, 31, .85);
box-shadow: 0 0 0 4px rgba(255, 77, 31, .25);
pointer-events: none;
will-change: transform;
}
.pvz__label {
position: absolute;
left: $sp-2;
bottom: $sp-2;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvz__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.pvz__page, .pvz__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvz {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
outline: none;
}
.pvz__view {
position: absolute;
inset: 0;
overflow: hidden;
}
.pvz__page {
position: absolute;
left: 12%;
top: 10%;
width: 76%;
height: 80%;
background: #fff7e6;
border-radius: 8px;
padding: 16px;
box-sizing: border-box;
will-change: transform;
}
.pvz__title {
height: 10px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvz__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvz__fig {
height: 22%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvz__dot {
position: absolute;
width: 14px;
height: 14px;
margin-left: -7px;
margin-top: -7px;
border-radius: 50%;
background: rgba(255, 77, 31, 0.85);
box-shadow: 0 0 0 4px rgba(255, 77, 31, 0.25);
pointer-events: none;
will-change: transform;
}
.pvz__label {
position: absolute;
left: 8px;
bottom: 8px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvz__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvz__page, .pvz__dot {
transition: none;
}
}(function () {
// 변형 02: ZOOM_FOCAL_STEP_RATIO 1.2 → 2.4 (전값이 소스 초기값)
// 기법: 휠(또는 시연 스케줄)이 일어난 지점의 좌표를 페이지 폭·높이 기준 비율(0~1)로 바꿔
// transform-origin 으로 쓴다. 확대율은 그 비율의 거듭제곱(ZOOM_FOCAL_STEP_RATIO^n)으로
// 계산해, 값이 클수록 같은 조작에도 훨씬 크게 뛴다. 초점 표식(dot)의 반경은 1/scale 로
// 역보정해 확대돼도 항상 같은 크기로 보인다(계산 전파).
var root = document.getElementById('pvzRoot');
var view = document.getElementById('pvzView');
var page = document.getElementById('pvzPage');
var dot = document.getElementById('pvzDot');
var label = document.getElementById('pvzLabel');
var ZOOM_FOCAL_STEP_RATIO = 1.2;
var LOOP_MS = 2000;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430021);
var lineCount = 7;
var linesHTML = '<div class="pvz__title" style="width:52%"></div>';
for (var i = 0; i < lineCount; i += 1) linesHTML += '<div class="pvz__line" style="width:' + (40 + rnd() * 52) + '%"></div>';
linesHTML += '<div class="pvz__fig"></div>';
page.innerHTML = linesHTML;
var L1 = ZOOM_FOCAL_STEP_RATIO;
var L2 = ZOOM_FOCAL_STEP_RATIO * ZOOM_FOCAL_STEP_RATIO;
var KF = [
{ t: 0, s: 1, x: .5, y: .5 },
{ t: 300, s: L1, x: .28, y: .38 },
{ t: 900, s: L2, x: .28, y: .38 },
{ t: 1300, s: 1, x: .28, y: .38 },
{ t: 1500, s: L1, x: .7, y: .64 },
{ t: 2000, s: 1, x: .7, y: .64 }
];
function easeInOut(t) { return t * t * (3 - 2 * t); }
function render(scale, ox, oy) {
page.style.transformOrigin = (ox * 100) + '% ' + (oy * 100) + '%';
page.style.transform = 'scale(' + scale.toFixed(3) + ')';
var vr = view.getBoundingClientRect();
dot.style.left = (ox * vr.width) + 'px';
dot.style.top = (oy * vr.height) + 'px';
dot.style.transform = 'translate(-50%,-50%) scale(' + (1 / scale).toFixed(3) + ')';
label.textContent = Math.round(scale * 100) + '%';
}
function scheduled(elapsed) {
var phase = elapsed % LOOP_MS;
var i = 0;
while (i < KF.length - 1 && phase >= KF[i + 1].t) i += 1;
var a = KF[i], b = KF[Math.min(i + 1, KF.length - 1)];
var span = Math.max(1, b.t - a.t);
var t = easeInOut(Math.max(0, Math.min(1, (phase - a.t) / span)));
var s = a.s + (b.s - a.s) * t;
render(s, b.x, b.y);
}
var manual = { s: 1, x: .5, y: .5 };
function applyWheel(dir, ox, oy) {
manual.s = Math.max(1, Math.min(L2 * 1.4, manual.s * Math.pow(ZOOM_FOCAL_STEP_RATIO, dir * 0.6)));
manual.x = ox; manual.y = oy;
render(manual.s, manual.x, manual.y);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); manual.s = 1; render(1, .5, .5); } }
view.addEventListener('wheel', function (e) {
e.preventDefault();
markActive();
var r = view.getBoundingClientRect();
var ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
applyWheel(e.deltaY < 0 ? 1 : -1, ox, oy);
}, { passive: false });
root.addEventListener('keydown', function (e) {
if (e.key === '+' || e.key === '=' || e.key === '-' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
markActive();
var dir = (e.key === '+' || e.key === '=' || e.key === 'ArrowUp') ? 1 : -1;
applyWheel(dir, manual.x, manual.y);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; render(1, .5, .5); }
var lastTs = null, elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; scheduled(elapsed); }
requestAnimationFrame(tick);
}
render(1, .5, .5);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerPointerZoomFocalPoint.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type WheelEvt = { deltaY: number; clientX: number; clientY: number; preventDefault(): void };
export default function PdfViewerPointerZoomFocalPoint() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const dotRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, view = viewRef.current, page = pageRef.current;
const dot = dotRef.current, label = labelRef.current;
if (!root || !view || !page || !dot || !label) return;
// 변형 02: ZOOM_FOCAL_STEP_RATIO 1.2 → 2.4 (전값이 소스 초기값)
const ZOOM_FOCAL_STEP_RATIO = 1.2;
const LOOP_MS = 2000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430020);
let linesHTML = '<div class="' + styles.title + '" style="width:52%"></div>';
for (let i = 0; i < 7; i += 1) linesHTML += '<div class="' + styles.line + '" style="width:' + (40 + rnd() * 52) + '%"></div>';
linesHTML += '<div class="' + styles.fig + '"></div>';
page.innerHTML = linesHTML;
const L1 = ZOOM_FOCAL_STEP_RATIO;
const L2 = ZOOM_FOCAL_STEP_RATIO * ZOOM_FOCAL_STEP_RATIO;
const KF = [
{ t: 0, s: 1, x: .5, y: .5 },
{ t: 300, s: L1, x: .28, y: .38 },
{ t: 900, s: L2, x: .28, y: .38 },
{ t: 1300, s: 1, x: .28, y: .38 },
{ t: 1500, s: L1, x: .7, y: .64 },
{ t: 2000, s: 1, x: .7, y: .64 }
];
function easeInOut(t: number) { return t * t * (3 - 2 * t); }
function render(scale: number, ox: number, oy: number) {
page!.style.transformOrigin = (ox * 100) + "% " + (oy * 100) + "%";
page!.style.transform = "scale(" + scale.toFixed(3) + ")";
const vr = view!.getBoundingClientRect();
dot!.style.left = (ox * vr.width) + "px";
dot!.style.top = (oy * vr.height) + "px";
dot!.style.transform = "translate(-50%,-50%) scale(" + (1 / scale).toFixed(3) + ")";
label!.textContent = Math.round(scale * 100) + "%";
}
function scheduled(elapsed: number) {
const phase = elapsed % LOOP_MS;
let i = 0;
while (i < KF.length - 1 && phase >= KF[i + 1].t) i += 1;
const a = KF[i], b = KF[Math.min(i + 1, KF.length - 1)];
const span = Math.max(1, b.t - a.t);
const t = easeInOut(Math.max(0, Math.min(1, (phase - a.t) / span)));
const s = a.s + (b.s - a.s) * t;
render(s, b.x, b.y);
}
const manual = { s: 1, x: .5, y: .5 };
let userActive = false;
function applyWheel(dir: number, ox: number, oy: number) {
manual.s = Math.max(1, Math.min(L2 * 1.4, manual.s * Math.pow(ZOOM_FOCAL_STEP_RATIO, dir * 0.6)));
manual.x = ox; manual.y = oy;
render(manual.s, manual.x, manual.y);
}
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); manual.s = 1; render(1, .5, .5); } }
function onWheel(e: WheelEvt) {
e.preventDefault();
markActive();
const r = view!.getBoundingClientRect();
const ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
applyWheel(e.deltaY < 0 ? 1 : -1, ox, oy);
}
function onKeydown(e: KeyEvt) {
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
markActive();
const dir = (e.key === "+" || e.key === "=" || e.key === "ArrowUp") ? 1 : -1;
applyWheel(dir, manual.x, manual.y);
}
}
view.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; render(1, .5, .5); }
let lastTs: number | null = null, elapsed = 0, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; scheduled(elapsed); }
raf = requestAnimationFrame(tick);
}
render(1, .5, .5);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
view.removeEventListener("wheel", onWheel as unknown as EventListener);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvz} is-demo`} ref={rootRef} tabIndex={0}>
<div className={styles.view} ref={viewRef}>
<div className={styles.page} ref={pageRef} />
<div className={styles.dot} ref={dotRef} />
</div>
<p className={styles.label} ref={labelRef}>100%</p>
</div>
</main>
);
}02 · 포인터 좌표를 기준으로 확대 중심을 계산하는 초점 줌 — 측정 누적면적 57.451% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyWheel(dir, ox, oy) {
manual.s = Math.max(1, Math.min(L2 * 1.4, manual.s * Math.pow(ZOOM_FOCAL_STEP_RATIO, dir * 0.6)));
manual.x = ox; manual.y = oy;
render(manual.s, manual.x, manual.y);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); manual.s = 1; render(1, .5, .5); } }
view.addEventListener('wheel', function (e) {
e.preventDefault();
markActive();
var r = view.getBoundingClientRect();
var ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
applyWheel(e.deltaY < 0 ? 1 : -1, ox, oy);
}, { passive: false });
03일치한 검색어 위치를 계산해 점프하는 검색 하이라이트
입력한 글자로 시작하는 낱말을 문서 전체에서 찾아 목록으로 모으고, 하나를 고르면 그 요소의 세로 위치를 계산해 스크롤 애니메이션 목표로 삼는다. 이동이 끝나는 지점은 요소 위쪽에서 뷰포트 높이의 35%만큼 여유를 두어 하이라이트된 글자가 화면 가운데 쪽에 오게 만든다. 검색 대상은 미리 심어 둔 낱말 목록(alpha·theta)이라 03 항목은 누적면적 40.3%·강도 평균 67.8·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="pvs is-demo" id="pvsRoot">
<div class="pvs__bar">
<input class="pvs__input" id="pvsInput" type="text" placeholder="검색" aria-label="문서 안 검색" autocomplete="off">
<span class="pvs__count" id="pvsCount">0개 일치</span>
</div>
<div class="pvs__body">
<div class="pvs__doc" id="pvsDoc"></div>
<div class="pvs__results" id="pvsResults"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.pvs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.pvs__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: $stage-ink;
}
.pvs__input {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 12px;
background: rgba(255, 255, 255, .92);
color: $subject-ink;
}
.pvs__count {
flex: 0 0 auto;
color: #fff;
font-size: 10px;
opacity: .8;
}
.pvs__body {
flex: 1 1 auto;
display: flex;
min-height: 0;
}
.pvs__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: $sp-2 $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.doc__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-2;
margin-bottom: $sp-2;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
}
.doc__title { height: 7px; border-radius: $r-xs; background: rgba(23, 20, 26, .32); margin-bottom: $sp-1; }
.pvs__lineText {
margin: $sp-1 0 0;
font-size: 9px;
line-height: 1.6;
color: rgba(23, 20, 26, .55);
}
.pvs__w {
border-radius: $r-xs;
padding: 0 $sp-1;
transition: background-color $dur-base ease, color $dur-base ease;
&--hit { background: $subject-blue-light; color: $subject-ink; font-weight: 700; }
}
.pvs__results {
flex: 0 0 auto;
width: 27%;
border-left: 1px solid rgba(23, 20, 26, .12);
overflow-y: auto;
padding: $sp-1;
display: flex;
flex-direction: column;
gap: $sp-1;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pvs__result {
border: none;
border-radius: $r-sm;
padding: $sp-1 $sp-2;
font-size: 9px;
text-align: left;
background: rgba(47, 109, 246, .12);
color: $subject-ink;
cursor: pointer;
}
@media (max-width: 340px) {
.pvs__lineText { font-size: 8px; }
.pvs__result { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.pvs__w { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.pvs__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #17141a;
}
.pvs__input {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
background: rgba(255, 255, 255, 0.92);
color: #17141a;
}
.pvs__count {
flex: 0 0 auto;
color: #fff;
font-size: 10px;
opacity: 0.8;
}
.pvs__body {
flex: 1 1 auto;
display: flex;
min-height: 0;
}
.pvs__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvs__doc::-webkit-scrollbar {
display: none;
}
.doc__page {
background: #fff;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
}
.doc__title {
height: 7px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.pvs__lineText {
margin: 4px 0 0;
font-size: 9px;
line-height: 1.6;
color: rgba(23, 20, 26, 0.55);
}
.pvs__w {
border-radius: 4px;
padding: 0 4px;
transition: background-color 300ms ease, color 300ms ease;
}
.pvs__w--hit {
background: #5b8cf8;
color: #17141a;
font-weight: 700;
}
.pvs__results {
flex: 0 0 auto;
width: 27%;
border-left: 1px solid rgba(23, 20, 26, 0.12);
overflow-y: auto;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: none;
}
.pvs__results::-webkit-scrollbar {
display: none;
}
.pvs__result {
border: none;
border-radius: 8px;
padding: 4px 8px;
font-size: 9px;
text-align: left;
background: rgba(47, 109, 246, 0.12);
color: #17141a;
cursor: pointer;
}
@media (max-width: 340px) {
.pvs__lineText {
font-size: 8px;
}
.pvs__result {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvs__w {
transition: none;
}
}(function () {
// 기법: 실제 텍스트 span 을 문서에 심어 두고, 검색어와 일치하는 span 을 순회로 찾아
// 페이지 번호(data-page)와 세로 위치(offsetTop)를 계산해 목록으로 모은다. 목록에서 하나를
// 고르면 그 위치까지 남은 거리를 이지(ease-out)로 나눠 스크롤하고, 도착 뒤 배경색을
// transition 으로 밝혔다 되돌린다.
var root = document.getElementById('pvsRoot');
var input = document.getElementById('pvsInput');
var count = document.getElementById('pvsCount');
var doc = document.getElementById('pvsDoc');
var resultsBox = document.getElementById('pvsResults');
var LOOP_MS = 2000;
var VOCAB = ['alpha', 'beta', 'gamma', 'delta', 'omega', 'sigma', 'kappa', 'lambda', 'theta', 'zeta'];
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function buildPages() {
var rnd = seedRand(430031), pages = [];
for (var p = 0; p < 4; p += 1) {
var lines = [];
for (var l = 0; l < 5; l += 1) {
var words = [];
for (var w = 0; w < 5; w += 1) words.push(VOCAB[Math.floor(rnd() * VOCAB.length)]);
lines.push(words);
}
pages.push(lines);
}
pages[1][2][0] = 'alpha';
pages[3][1][2] = 'alpha';
pages[0][4][3] = 'theta';
pages[2][0][1] = 'theta';
return pages;
}
var pages = buildPages();
pages.forEach(function (lines, pIdx) {
var page = document.createElement('div');
page.className = 'doc__page';
var title = document.createElement('div');
title.className = 'doc__title';
title.style.width = (46 + (pIdx % 3) * 10) + '%';
page.appendChild(title);
lines.forEach(function (words) {
var lineEl = document.createElement('p');
lineEl.className = 'pvs__lineText';
words.forEach(function (word, wi) {
var span = document.createElement('span');
span.className = 'pvs__w';
span.textContent = word;
lineEl.appendChild(span);
if (wi < words.length - 1) lineEl.appendChild(document.createTextNode(' '));
});
page.appendChild(lineEl);
});
doc.appendChild(page);
});
var words = Array.prototype.slice.call(doc.querySelectorAll('.pvs__w'));
function pageOf(el) {
var i = 1, node = el;
while (node && !node.classList.contains('doc__page')) node = node.parentElement;
var all = Array.prototype.slice.call(doc.children);
return all.indexOf(node) + 1;
}
function find(q) {
q = q.toLowerCase();
if (!q) return [];
return words.filter(function (w) { return w.textContent.toLowerCase().indexOf(q) === 0; });
}
var scrollAnim = null;
function startScroll(to, dur, nowMs) {
scrollAnim = { from: doc.scrollTop, to: to, start: nowMs, dur: dur };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var lastHit = null;
function jumpTo(el, nowMs) {
var target = Math.max(0, el.offsetTop - doc.clientHeight * 0.35);
startScroll(target, 460, nowMs);
if (lastHit) lastHit.classList.remove('pvs__w--hit');
lastHit = el;
}
function ignite(el) { el.classList.add('pvs__w--hit'); }
function extinguish(el) { el.classList.remove('pvs__w--hit'); }
function renderList(matches) {
count.textContent = matches.length + '개 일치';
resultsBox.innerHTML = '';
matches.slice(0, 5).forEach(function (el) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pvs__result';
b.textContent = '페이지 ' + pageOf(el);
b.addEventListener('click', function () { userActive = true; root.classList.remove('is-demo'); jumpTo(el, lastTs || 0); ignite(el); });
resultsBox.appendChild(b);
});
}
var userActive = false;
function onInput() {
userActive = true; root.classList.remove('is-demo');
var matches = find(input.value);
renderList(matches);
}
input.addEventListener('input', onInput);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
var matches = find(input.value);
if (matches.length) { userActive = true; root.classList.remove('is-demo'); jumpTo(matches[0], lastTs || 0); ignite(matches[0]); }
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
var fired = {};
var SCHEDULE = [
{ t: 0, run: function () { input.value = ''; renderList([]); startScroll(0, 260, elapsedNow); if (lastHit) { extinguish(lastHit); lastHit = null; } } },
{ t: 150, run: function () { input.value = 'al'; var m = find('al'); renderList(m); if (m[0]) jumpTo(m[0], elapsedNow); } },
{ t: 650, run: function () { if (lastHit) ignite(lastHit); } },
{ t: 980, run: function () { if (lastHit) extinguish(lastHit); } },
{ t: 1150, run: function () { input.value = ''; renderList([]); } },
{ t: 1300, run: function () { input.value = 'th'; var m = find('th'); renderList(m); var pick = m[1] || m[0]; if (pick) jumpTo(pick, elapsedNow); } },
{ t: 1780, run: function () { if (lastHit) ignite(lastHit); } }
];
var lastTs = null, elapsed = 0, elapsedNow = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
elapsed += dt;
elapsedNow = elapsed;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
if (scrollAnim) {
var p = Math.max(0, Math.min(1, (elapsedNow - scrollAnim.start) / scrollAnim.dur));
doc.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
requestAnimationFrame(tick);
}
renderList([]);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerSearchMatchHighlightJump.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
export default function PdfViewerSearchMatchHighlightJump() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current, input = inputRef.current, count = countRef.current;
const doc = docRef.current, resultsBox = resultsRef.current;
if (!root || !input || !count || !doc || !resultsBox) return;
const LOOP_MS = 2000;
const VOCAB = ["alpha", "beta", "gamma", "delta", "omega", "sigma", "kappa", "lambda", "theta", "zeta"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function buildPages() {
const rnd = seedRand(430031);
const pages: string[][][] = [];
for (let p = 0; p < 4; p += 1) {
const lines: string[][] = [];
for (let l = 0; l < 5; l += 1) {
const words: string[] = [];
for (let w = 0; w < 5; w += 1) words.push(VOCAB[Math.floor(rnd() * VOCAB.length)]);
lines.push(words);
}
pages.push(lines);
}
pages[1][2][0] = "alpha";
pages[3][1][2] = "alpha";
pages[0][4][3] = "theta";
pages[2][0][1] = "theta";
return pages;
}
const pages = buildPages();
pages.forEach((lines, pIdx) => {
const page = document.createElement("div");
page.className = styles.docPage;
const title = document.createElement("div");
title.className = styles.docTitle;
title.style.width = (46 + (pIdx % 3) * 10) + "%";
page.appendChild(title);
lines.forEach((words) => {
const lineEl = document.createElement("p");
lineEl.className = styles.lineText;
words.forEach((word, wi) => {
const span = document.createElement("span");
span.className = styles.w;
span.textContent = word;
lineEl.appendChild(span);
if (wi < words.length - 1) lineEl.appendChild(document.createTextNode(" "));
});
page.appendChild(lineEl);
});
doc.appendChild(page);
});
const words = Array.prototype.slice.call(doc.querySelectorAll("." + styles.w)) as HTMLElement[];
function pageOf(el: HTMLElement) {
let node: HTMLElement | null = el;
while (node && !node.classList.contains(styles.docPage)) node = node.parentElement;
const all = Array.prototype.slice.call(doc!.children) as Element[];
return all.indexOf(node as Element) + 1;
}
function find(q: string) {
q = q.toLowerCase();
if (!q) return [] as HTMLElement[];
return words.filter((w) => w.textContent!.toLowerCase().indexOf(q) === 0);
}
let scrollAnim: { from: number; to: number; start: number; dur: number } | null = null;
function startScroll(to: number, dur: number, nowMs: number) {
scrollAnim = { from: doc!.scrollTop, to, start: nowMs, dur };
}
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
let lastHit: HTMLElement | null = null;
function jumpTo(el: HTMLElement, nowMs: number) {
const target = Math.max(0, el.offsetTop - doc!.clientHeight * 0.35);
startScroll(target, 460, nowMs);
if (lastHit) lastHit.classList.remove(styles.hit);
lastHit = el;
}
function ignite(el: HTMLElement) { el.classList.add(styles.hit); }
function extinguish(el: HTMLElement) { el.classList.remove(styles.hit); }
function renderList(matches: HTMLElement[]) {
count!.textContent = matches.length + "개 일치";
resultsBox!.innerHTML = "";
matches.slice(0, 5).forEach((el) => {
const b = document.createElement("button");
b.type = "button";
b.className = styles.result;
b.textContent = "페이지 " + pageOf(el);
b.addEventListener("click", () => { userActive = true; root!.classList.remove("is-demo"); jumpTo(el, lastTs || 0); ignite(el); });
resultsBox!.appendChild(b);
});
}
let userActive = false;
function onInput(e: InputEvt) {
userActive = true; root!.classList.remove("is-demo");
const matches = find(e.target.value);
renderList(matches);
}
function onKeydown(e: KeyEvt) {
if (e.key === "Enter") {
e.preventDefault();
const matches = find(input!.value);
if (matches.length) { userActive = true; root!.classList.remove("is-demo"); jumpTo(matches[0], lastTs || 0); ignite(matches[0]); }
}
}
input.addEventListener("input", onInput as unknown as EventListener);
input.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; }
const fired: Record<string, boolean> = {};
let elapsedNow = 0;
const SCHEDULE = [
{ t: 0, run: () => { input!.value = ""; renderList([]); startScroll(0, 260, elapsedNow); if (lastHit) { extinguish(lastHit); lastHit = null; } } },
{ t: 150, run: () => { input!.value = "al"; const m = find("al"); renderList(m); if (m[0]) jumpTo(m[0], elapsedNow); } },
{ t: 650, run: () => { if (lastHit) ignite(lastHit); } },
{ t: 980, run: () => { if (lastHit) extinguish(lastHit); } },
{ t: 1150, run: () => { input!.value = ""; renderList([]); } },
{ t: 1300, run: () => { input!.value = "th"; const m = find("th"); renderList(m); const pick = m[1] || m[0]; if (pick) jumpTo(pick, elapsedNow); } },
{ t: 1780, run: () => { if (lastHit) ignite(lastHit); } }
];
let lastTs: number | null = null, elapsed = 0, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
elapsed += dt;
elapsedNow = elapsed;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
if (scrollAnim) {
const p = Math.max(0, Math.min(1, (elapsedNow - scrollAnim.start) / scrollAnim.dur));
doc!.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
raf = requestAnimationFrame(tick);
}
renderList([]);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
input.removeEventListener("input", onInput as unknown as EventListener);
input.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvs} is-demo`} ref={rootRef}>
<div className={styles.bar}>
<input className={styles.input} ref={inputRef} type="text" placeholder="검색" aria-label="문서 안 검색" autoComplete="off" />
<span className={styles.count} ref={countRef}>0개 일치</span>
</div>
<div className={styles.body}>
<div className={styles.doc} ref={docRef} />
<div className={styles.results} ref={resultsRef} />
</div>
</div>
</main>
);
}03 · 일치한 검색어 위치를 계산해 점프하는 검색 하이라이트 — 측정 누적면적 40.346% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function find(q) {
q = q.toLowerCase();
if (!q) return [];
return words.filter(function (w) { return w.textContent.toLowerCase().indexOf(q) === 0; });
}
var scrollAnim = null;
function startScroll(to, dur, nowMs) {
scrollAnim = { from: doc.scrollTop, to: to, start: nowMs, dur: dur };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var lastHit = null;
function jumpTo(el, nowMs) {
var target = Math.max(0, el.offsetTop - doc.clientHeight * 0.35);
startScroll(target, 460, nowMs);
if (lastHit) lastHit.classList.remove('pvs__w--hit');
lastHit = el;
}
function ignite(el) { el.classList.add('pvs__w--hit'); }
04손을 뗄 때의 속도로 다음 장 여부를 계산하는 스와이프 플립
최근 120ms 안의 포인터 이동 샘플만 남겨두고 그 구간의 이동 거리를 경과 시간으로 나눠 px/s 단위 속도를 계산한다. 이 값이 SWIPE_FLIP_VELOCITY_PX_S 기준을 넘으면 카드가 그 방향으로 날아가고, 못 넘으면 원래 자리로 스프링처럼 되돌아간다. 04 항목은 누적면적 42.2%·강도 평균 27.2·23프레임 중 10프레임에서 움직였다(스와이프 사이 정지 구간이 있어 나머지는 정지 프레임이다).

<main class="stage">
<div class="pvf is-demo" id="pvfRoot" tabindex="0" aria-label="페이지 스와이프 카드">
<div class="pvf__stack" id="pvfStack">
<div class="pvf__cardBack" id="pvfCardBack"></div>
<div class="pvf__card" id="pvfCard"></div>
</div>
<p class="pvf__hint" id="pvfHint">좌우로 밀기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pvf {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: grid;
place-items: center;
outline: none;
}
.pvf__stack { position: relative; width: 62%; height: 84%; }
.pvf__cardBack,
.pvf__card {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: $sp-4;
box-sizing: border-box;
}
.pvf__cardBack {
transform: scale(.94) translateY(6%);
box-shadow: 0 8px 16px rgba(23, 20, 26, .18);
}
.pvf__card {
cursor: grab;
touch-action: none;
will-change: transform;
}
.pvf__title { height: 10px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-3; }
.pvf__line { height: 6px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-2; }
.pvf__fig { height: 24%; margin-top: $sp-3; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvf__hint {
position: absolute;
bottom: $sp-2;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .5);
transition: opacity $dur-base ease;
}
@media (max-width: 340px) {
.pvf__stack { width: 74%; }
}
@media (prefers-reduced-motion: reduce) {
.pvf__card { transition: none; }
}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);
}
.pvf {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: grid;
place-items: center;
outline: none;
}
.pvf__stack {
position: relative;
width: 62%;
height: 84%;
}
.pvf__cardBack,
.pvf__card {
position: absolute;
inset: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
box-sizing: border-box;
}
.pvf__cardBack {
transform: scale(0.94) translateY(6%);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.18);
}
.pvf__card {
cursor: grab;
touch-action: none;
will-change: transform;
}
.pvf__title {
height: 10px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 12px;
}
.pvf__line {
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 8px;
}
.pvf__fig {
height: 24%;
margin-top: 12px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvf__hint {
position: absolute;
bottom: 8px;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
transition: opacity 300ms ease;
}
@media (max-width: 340px) {
.pvf__stack {
width: 74%;
}
}
@media (prefers-reduced-motion: reduce) {
.pvf__card {
transition: none;
}
}(function () {
// 기법: 포인터를 떼기 직전 몇 개의 샘플(좌표+시각)만 남겨 두고, 그 구간의 이동 거리를
// 경과 시간으로 나눠 px/s 속도를 계산한다. 기준(SWIPE_FLIP_VELOCITY_PX_S)을 넘으면
// 카드가 화면 밖으로 날아가 다음 페이지로 넘어가고, 못 넘으면 스프링으로 제자리에 되돌아간다.
// 자동 시연도 같은 push/release 함수를 그대로 호출해 실제 입력과 동일 경로를 탄다.
var root = document.getElementById('pvfRoot');
var card = document.getElementById('pvfCard');
var cardBack = document.getElementById('pvfCardBack');
var hint = document.getElementById('pvfHint');
// 변형 04: SWIPE_FLIP_VELOCITY_PX_S 300 → 600 (전값이 소스 초기값)
var SWIPE_FLIP_VELOCITY_PX_S = 300;
var LOOP_MS = 2000;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var pageSeed = 430041;
function paintPage(el) {
var rnd = seedRand(pageSeed);
pageSeed += 7;
var html = '<div class="pvf__title" style="width:' + (44 + rnd() * 16) + '%"></div>';
for (var i = 0; i < 6; i += 1) html += '<div class="pvf__line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.3) html += '<div class="pvf__fig"></div>';
el.innerHTML = html;
}
paintPage(card);
paintPage(cardBack);
var dragging = false, samples = [], dragStartX = 0, cardStartX = 0;
var curX = 0, curRot = 0, flying = false, flyAnim = null, springAnim = null;
function pushSample(x, t) { samples.push({ x: x, t: t }); if (samples.length > 10) samples.shift(); }
function render() {
card.style.transform = 'translateX(' + curX.toFixed(1) + 'px) rotate(' + curRot.toFixed(2) + 'deg)';
card.style.opacity = String(Math.max(0, 1 - Math.abs(curX) / 460));
}
function velocityNow(nowT) {
if (samples.length < 2) return 0;
var windowStart = nowT - 120, first = samples[0];
for (var i = 0; i < samples.length; i += 1) { if (samples[i].t >= windowStart) { first = samples[i]; break; } }
var last = samples[samples.length - 1];
var dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.x - first.x) / dt;
}
function startDrag(x, t) { dragging = true; flying = false; springAnim = null; samples = []; dragStartX = x; cardStartX = curX; pushSample(x, t); }
function moveDrag(x, t) {
if (!dragging) return;
var dx = x - dragStartX;
curX = cardStartX + dx;
curRot = dx / 22;
pushSample(x, t);
render();
}
function release(t) {
if (!dragging) return;
dragging = false;
var v = velocityNow(t);
if (Math.abs(v) >= SWIPE_FLIP_VELOCITY_PX_S) flyOut(v < 0 ? -1 : 1, t);
else springBack(t);
}
function flyOut(dir, nowT) {
flying = true;
flyAnim = { fromX: curX, fromRot: curRot, toX: dir * 640, toRot: dir * 26, start: nowT, dur: 260 };
}
function springBack(nowT) {
springAnim = { fromX: curX, toX: 0, fromRot: curRot, toRot: 0, start: nowT, dur: 520 };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function springEase(t) {
// 오버슛이 있는 감쇠 스프링 근사
var d = 7.2, w = 5.6;
return 1 - Math.exp(-d * t) * Math.cos(w * t);
}
card.addEventListener('pointerdown', function (e) { markActive(); card.setPointerCapture(e.pointerId); startDrag(e.clientX, e.timeStamp); });
card.addEventListener('pointermove', function (e) { if (dragging) moveDrag(e.clientX, e.timeStamp); });
card.addEventListener('pointerup', function (e) { release(e.timeStamp); });
card.addEventListener('pointercancel', function (e) { release(e.timeStamp); });
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); hint.style.opacity = '0'; } }
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
markActive();
flyOut(e.key === 'ArrowLeft' ? -1 : 1, lastTs || 0);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; hint.style.opacity = '0'; }
var fired = {};
var SCHEDULE = [
{ t: 20, run: function (nowT) { paintPage(cardBack); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 420, run: function (nowT) { moveDrag(-360, nowT); } },
{ t: 640, run: function (nowT) { release(nowT); } },
{ t: 950, run: function (nowT) { paintPage(card); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 1350, run: function (nowT) { moveDrag(-70, nowT); } },
{ t: 1650, run: function (nowT) { release(nowT); } }
];
var lastTs = null, elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
elapsed = ts - (tick.originTs === undefined ? (tick.originTs = ts) : tick.originTs);
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (flyAnim) {
var p = Math.max(0, Math.min(1, (ts - flyAnim.start) / flyAnim.dur));
var e = easeOutCubic(p);
curX = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
curRot = flyAnim.fromRot + (flyAnim.toRot - flyAnim.fromRot) * e;
render();
if (p >= 1) { flyAnim = null; flying = false; }
} else if (springAnim) {
var sp = Math.max(0, Math.min(1, (ts - springAnim.start) / springAnim.dur));
var se = springEase(sp);
curX = springAnim.fromX + (springAnim.toX - springAnim.fromX) * se;
curRot = springAnim.fromRot + (springAnim.toRot - springAnim.fromRot) * se;
render();
if (sp >= 1) { curX = 0; curRot = 0; render(); springAnim = null; }
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerSwipeVelocityPageFlip.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type PointerEvt = { clientX: number; timeStamp: number; pointerId: number; preventDefault(): void };
export default function PdfViewerSwipeVelocityPageFlip() {
const rootRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const cardBackRef = useRef<HTMLDivElement>(null);
const hintRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, card = cardRef.current, cardBack = cardBackRef.current, hint = hintRef.current;
if (!root || !card || !cardBack || !hint) return;
// 변형 04: SWIPE_FLIP_VELOCITY_PX_S 300 → 600 (전값이 소스 초기값)
const SWIPE_FLIP_VELOCITY_PX_S = 300;
const LOOP_MS = 2000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
let pageSeed = 430041;
function paintPage(el: HTMLElement) {
const rnd = seedRand(pageSeed);
pageSeed += 7;
let html = '<div class="' + styles.title + '" style="width:' + (44 + rnd() * 16) + '%"></div>';
for (let i = 0; i < 6; i += 1) html += '<div class="' + styles.line + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.3) html += '<div class="' + styles.fig + '"></div>';
el.innerHTML = html;
}
paintPage(card);
paintPage(cardBack);
let dragging = false;
let samples: { x: number; t: number }[] = [];
let dragStartX = 0, cardStartX = 0, curX = 0, curRot = 0;
let flyAnim: { fromX: number; fromRot: number; toX: number; toRot: number; start: number; dur: number } | null = null;
let springAnim: { fromX: number; toX: number; fromRot: number; toRot: number; start: number; dur: number } | null = null;
function pushSample(x: number, t: number) { samples.push({ x, t }); if (samples.length > 10) samples.shift(); }
function render() {
card!.style.transform = "translateX(" + curX.toFixed(1) + "px) rotate(" + curRot.toFixed(2) + "deg)";
card!.style.opacity = String(Math.max(0, 1 - Math.abs(curX) / 460));
}
function velocityNow(nowT: number) {
if (samples.length < 2) return 0;
const windowStart = nowT - 120;
let first = samples[0];
for (let i = 0; i < samples.length; i += 1) { if (samples[i].t >= windowStart) { first = samples[i]; break; } }
const last = samples[samples.length - 1];
const dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.x - first.x) / dt;
}
function startDrag(x: number, t: number) { dragging = true; flyAnim = null; springAnim = null; samples = []; dragStartX = x; cardStartX = curX; pushSample(x, t); }
function moveDrag(x: number, t: number) {
if (!dragging) return;
const dx = x - dragStartX;
curX = cardStartX + dx;
curRot = dx / 22;
pushSample(x, t);
render();
}
function release(t: number) {
if (!dragging) return;
dragging = false;
const v = velocityNow(t);
if (Math.abs(v) >= SWIPE_FLIP_VELOCITY_PX_S) flyOut(v < 0 ? -1 : 1, t);
else springBack(t);
}
function flyOut(dir: number, nowT: number) { flyAnim = { fromX: curX, fromRot: curRot, toX: dir * 640, toRot: dir * 26, start: nowT, dur: 260 }; }
function springBack(nowT: number) { springAnim = { fromX: curX, toX: 0, fromRot: curRot, toRot: 0, start: nowT, dur: 520 }; }
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function springEase(t: number) { const d = 7.2, w = 5.6; return 1 - Math.exp(-d * t) * Math.cos(w * t); }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); hint!.style.opacity = "0"; } }
function onPointerDown(e: PointerEvt) { markActive(); (card as unknown as { setPointerCapture(id: number): void }).setPointerCapture(e.pointerId); startDrag(e.clientX, e.timeStamp); }
function onPointerMove(e: PointerEvt) { if (dragging) moveDrag(e.clientX, e.timeStamp); }
function onPointerUp(e: PointerEvt) { release(e.timeStamp); }
card.addEventListener("pointerdown", onPointerDown as unknown as EventListener);
card.addEventListener("pointermove", onPointerMove as unknown as EventListener);
card.addEventListener("pointerup", onPointerUp as unknown as EventListener);
card.addEventListener("pointercancel", onPointerUp as unknown as EventListener);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
markActive();
flyOut(e.key === "ArrowLeft" ? -1 : 1, lastTs || 0);
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; hint.style.opacity = "0"; }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 20, run: (nowT: number) => { paintPage(cardBack!); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 420, run: (nowT: number) => { moveDrag(-360, nowT); } },
{ t: 640, run: (nowT: number) => { release(nowT); } },
{ t: 950, run: (nowT: number) => { paintPage(card!); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 1350, run: (nowT: number) => { moveDrag(-70, nowT); } },
{ t: 1650, run: (nowT: number) => { release(nowT); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (flyAnim) {
const p = Math.max(0, Math.min(1, (ts - flyAnim.start) / flyAnim.dur));
const e = easeOutCubic(p);
curX = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
curRot = flyAnim.fromRot + (flyAnim.toRot - flyAnim.fromRot) * e;
render();
if (p >= 1) flyAnim = null;
} else if (springAnim) {
const sp = Math.max(0, Math.min(1, (ts - springAnim.start) / springAnim.dur));
const se = springEase(sp);
curX = springAnim.fromX + (springAnim.toX - springAnim.fromX) * se;
curRot = springAnim.fromRot + (springAnim.toRot - springAnim.fromRot) * se;
render();
if (sp >= 1) { curX = 0; curRot = 0; render(); springAnim = null; }
}
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
card.removeEventListener("pointerdown", onPointerDown as unknown as EventListener);
card.removeEventListener("pointermove", onPointerMove as unknown as EventListener);
card.removeEventListener("pointerup", onPointerUp as unknown as EventListener);
card.removeEventListener("pointercancel", onPointerUp as unknown as EventListener);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvf} is-demo`} ref={rootRef} tabIndex={0} aria-label="페이지 스와이프 카드">
<div className={styles.stack}>
<div className={styles.cardBack} ref={cardBackRef} />
<div className={styles.card} ref={cardRef} />
</div>
<p className={styles.hint} ref={hintRef}>좌우로 밀기</p>
</div>
</main>
);
}04 · 손을 뗄 때의 속도로 다음 장 여부를 계산하는 스와이프 플립 — 측정 누적면적 42.158% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function startDrag(x, t) { dragging = true; flying = false; springAnim = null; samples = []; dragStartX = x; cardStartX = curX; pushSample(x, t); }
function moveDrag(x, t) {
if (!dragging) return;
var dx = x - dragStartX;
curX = cardStartX + dx;
curRot = dx / 22;
pushSample(x, t);
render();
}
function release(t) {
if (!dragging) return;
dragging = false;
var v = velocityNow(t);
if (Math.abs(v) >= SWIPE_FLIP_VELOCITY_PX_S) flyOut(v < 0 ? -1 : 1, t);
else springBack(t);
}
05받은 바이트 수로 진행률을 계산하는 프로그레시브 로드 바
세 개의 사인 함수를 더해 "빨라졌다 느려졌다"하는 다운로드 속도를 결정적으로 만들고, 그 속도를 매 프레임 누적해 받은 바이트 수를 늘린다. 진행률은 받은 바이트를 전체 크기로 나눈 값이고, 남은 페이지 수는 그 반대 비율에 전체 페이지 수를 곱해 표시한다. 진행 표시가 중심인 항목이라 05 항목은 누적면적 2.8%·강도 평균 114.3·23프레임 중 22프레임에서 움직였다 — 막대와 숫자만 바뀌고 화면 대부분은 그대로다.

<main class="stage">
<div class="pvb is-demo" id="pvbRoot" tabindex="0" aria-label="다운로드 진행 막대, 스페이스로 정지·화살표로 속도 조절">
<p class="pvb__title">문서.pdf</p>
<div class="pvb__track" id="pvbTrack">
<div class="pvb__fill" id="pvbFill"></div>
</div>
<div class="pvb__row">
<span class="pvb__pct" id="pvbPct">0%</span>
<span class="pvb__bytes" id="pvbBytes">0 / 0 KB</span>
</div>
<p class="pvb__pages" id="pvbPages">남은 페이지 12</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pvb {
position: relative;
width: min(84vw, calc(140vh - 11.2vw));
margin: 0 auto;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: $sp-5 $sp-4;
box-sizing: border-box;
outline: none;
}
.pvb__title {
margin: 0 0 $sp-3;
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
.pvb__track {
position: relative;
height: 10px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
overflow: hidden;
}
.pvb__fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: $r-pill;
background: linear-gradient(90deg, $subject-blue, $subject-blue-light);
}
.pvb__row {
display: flex;
justify-content: space-between;
margin-top: $sp-2;
font-size: 11px;
color: rgba(23, 20, 26, .6);
}
.pvb__pct { font-weight: 700; color: $subject-ink; }
.pvb__pages {
margin: $sp-2 0 0;
font-size: 11px;
color: rgba(23, 20, 26, .55);
}
@media (max-width: 340px) {
.pvb { padding: $sp-4 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.pvb__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvb {
position: relative;
width: min(84vw, 140vh - 11.2vw);
margin: 0 auto;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 20px 16px;
box-sizing: border-box;
outline: none;
}
.pvb__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.pvb__track {
position: relative;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.pvb__fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #2f6df6, #5b8cf8);
}
.pvb__row {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 11px;
color: rgba(23, 20, 26, 0.6);
}
.pvb__pct {
font-weight: 700;
color: #17141a;
}
.pvb__pages {
margin: 8px 0 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.55);
}
@media (max-width: 340px) {
.pvb {
padding: 16px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvb__fill {
transition: none;
}
}(function () {
// 기법: 초당 전송 속도를 사인 합성 곡선(결정적, 시드 없이도 매 실행 동일)으로 만들어
// 프레임마다 받은 바이트 수에 더한다. 받은 바이트 / 전체 바이트로 진행률을 매 순간
// 다시 계산해 막대 너비와 남은 페이지 수(전체 페이지 중 아직 못 받은 비율)를 함께 갱신한다.
var root = document.getElementById('pvbRoot');
var fill = document.getElementById('pvbFill');
var pctEl = document.getElementById('pvbPct');
var bytesEl = document.getElementById('pvbBytes');
var pagesEl = document.getElementById('pvbPages');
var TOTAL_BYTES = 480000;
var TOTAL_PAGES = 12;
var HOLD_MS = 260;
function speedCurve(tSec) {
// 결정적 의사 지터: 세 개의 사인을 더해 "빨라졌다 느려졌다" 하는 네트워크 느낌
var base = 210000;
var v = base
+ 90000 * Math.sin(tSec * 2.3)
+ 40000 * Math.sin(tSec * 5.1 + 1.4)
+ 20000 * Math.sin(tSec * 9.7 + 0.6);
return Math.max(30000, v);
}
var received = 0, speedMul = 1, paused = false, holdT = 0;
function render() {
var ratio = Math.max(0, Math.min(1, received / TOTAL_BYTES));
fill.style.width = (ratio * 100).toFixed(1) + '%';
pctEl.textContent = Math.round(ratio * 100) + '%';
bytesEl.textContent = Math.round(received / 1000) + ' / ' + Math.round(TOTAL_BYTES / 1000) + ' KB';
var remain = Math.ceil((1 - ratio) * TOTAL_PAGES);
pagesEl.textContent = ratio >= 1 ? '전체 페이지 준비 완료' : ('남은 페이지 ' + remain);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); markActive(); paused = !paused; }
else if (e.key === 'ArrowUp') { e.preventDefault(); markActive(); speedMul = Math.min(4, speedMul * 1.5); }
else if (e.key === 'ArrowDown') { e.preventDefault(); markActive(); speedMul = Math.max(0.25, speedMul / 1.5); }
});
root.addEventListener('click', function () { markActive(); paused = !paused; });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; received = TOTAL_BYTES * 0.5; render(); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
if (received >= TOTAL_BYTES) {
holdT += dt;
if (holdT >= HOLD_MS) { received = 0; holdT = 0; }
} else {
var sec = ts / 1000;
received = Math.min(TOTAL_BYTES, received + speedCurve(sec) * speedMul * (dt / 1000));
}
render();
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerByteProgressLoadBar.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerByteProgressLoadBar() {
const rootRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const pctRef = useRef<HTMLSpanElement>(null);
const bytesRef = useRef<HTMLSpanElement>(null);
const pagesRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, fill = fillRef.current, pctEl = pctRef.current;
const bytesEl = bytesRef.current, pagesEl = pagesRef.current;
if (!root || !fill || !pctEl || !bytesEl || !pagesEl) return;
const TOTAL_BYTES = 480000;
const TOTAL_PAGES = 12;
const HOLD_MS = 260;
function speedCurve(tSec: number) {
const base = 210000;
const v = base
+ 90000 * Math.sin(tSec * 2.3)
+ 40000 * Math.sin(tSec * 5.1 + 1.4)
+ 20000 * Math.sin(tSec * 9.7 + 0.6);
return Math.max(30000, v);
}
let received = 0, speedMul = 1, paused = false, holdT = 0;
function render() {
const ratio = Math.max(0, Math.min(1, received / TOTAL_BYTES));
fill!.style.width = (ratio * 100).toFixed(1) + "%";
pctEl!.textContent = Math.round(ratio * 100) + "%";
bytesEl!.textContent = Math.round(received / 1000) + " / " + Math.round(TOTAL_BYTES / 1000) + " KB";
const remain = Math.ceil((1 - ratio) * TOTAL_PAGES);
pagesEl!.textContent = ratio >= 1 ? "전체 페이지 준비 완료" : ("남은 페이지 " + remain);
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onKeydown(e: KeyEvt) {
if (e.key === " " || e.key === "Spacebar") { e.preventDefault(); markActive(); paused = !paused; }
else if (e.key === "ArrowUp") { e.preventDefault(); markActive(); speedMul = Math.min(4, speedMul * 1.5); }
else if (e.key === "ArrowDown") { e.preventDefault(); markActive(); speedMul = Math.max(0.25, speedMul / 1.5); }
}
function onClick() { markActive(); paused = !paused; }
root.addEventListener("keydown", onKeydown as unknown as EventListener);
root.addEventListener("click", onClick);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; received = TOTAL_BYTES * 0.5; render(); }
let lastTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
if (received >= TOTAL_BYTES) {
holdT += dt;
if (holdT >= HOLD_MS) { received = 0; holdT = 0; }
} else {
const sec = ts / 1000;
received = Math.min(TOTAL_BYTES, received + speedCurve(sec) * speedMul * (dt / 1000));
}
render();
}
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
root.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvb} is-demo`} ref={rootRef} tabIndex={0} aria-label="다운로드 진행 막대, 스페이스로 정지·화살표로 속도 조절">
<p className={styles.title}>문서.pdf</p>
<div className={styles.track}>
<div className={styles.fill} ref={fillRef} />
</div>
<div className={styles.row}>
<span className={styles.pct} ref={pctRef}>0%</span>
<span className={styles.bytesTxt} ref={bytesRef}>0 / 0 KB</span>
</div>
<p className={styles.pages} ref={pagesRef}>남은 페이지 12</p>
</div>
</main>
);
}05 · 받은 바이트 수로 진행률을 계산하는 프로그레시브 로드 바 — 측정 누적면적 2.847% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function speedCurve(tSec) {
// 결정적 의사 지터: 세 개의 사인을 더해 "빨라졌다 느려졌다" 하는 네트워크 느낌
var base = 210000;
var v = base
+ 90000 * Math.sin(tSec * 2.3)
+ 40000 * Math.sin(tSec * 5.1 + 1.4)
+ 20000 * Math.sin(tSec * 9.7 + 0.6);
return Math.max(30000, v);
}
var received = 0, speedMul = 1, paused = false, holdT = 0;
function render() {
var ratio = Math.max(0, Math.min(1, received / TOTAL_BYTES));
fill.style.width = (ratio * 100).toFixed(1) + '%';
pctEl.textContent = Math.round(ratio * 100) + '%';
bytesEl.textContent = Math.round(received / 1000) + ' / ' + Math.round(TOTAL_BYTES / 1000) + ' KB';
var remain = Math.ceil((1 - ratio) * TOTAL_PAGES);
pagesEl.textContent = ratio >= 1 ? '전체 페이지 준비 완료' : ('남은 페이지 ' + remain);
}
0690도 단위로 각도를 맞추는 회전 스냅 컨트롤
버튼을 누를 때마다 목표 각도가 ROTATE_SNAP_DEG 만큼 커지고, 현재 각도는 목표까지 남은 차이의 18%씩 매 프레임 좁혀가며 계속 다시 계산된다. 각도가 바뀌는 매 프레임마다 그 각도 기준 가로세로 박스 크기도 다시 계산해, 회전이 끝나야 딱 맞춰지는 스냅이 아니라 지나가는 모든 각도에서 비율이 맞다. 06 항목은 누적면적 11.4%·강도 평균 96.2·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="pvr is-demo" id="pvrRoot">
<div class="pvr__frame" id="pvrFrame">
<div class="pvr__page" id="pvrPage"></div>
</div>
<div class="pvr__controls">
<button class="pvr__btn" id="pvrLeft" type="button" aria-label="왼쪽으로 회전">↺</button>
<span class="pvr__deg" id="pvrDeg">0°</span>
<button class="pvr__btn" id="pvrRight" type="button" aria-label="오른쪽으로 회전">↻</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pvr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-4;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: $subject-cream;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-3;
box-sizing: border-box;
will-change: transform;
}
.pvr__title { height: 9px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvr__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvr__fig { height: 26%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvr__controls {
display: flex;
align-items: center;
gap: $sp-3;
}
.pvr__btn {
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
font-size: 16px;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
@media (max-width: 340px) {
.pvr__btn { width: 28px; height: 28px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.pvr__frame {
position: relative;
transition: none;
}
.pvr__page {
position: absolute;
left: 50%;
top: 50%;
background: #fff7e6;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
will-change: transform;
}
.pvr__title {
height: 9px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvr__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvr__fig {
height: 26%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvr__controls {
display: flex;
align-items: center;
gap: 12px;
}
.pvr__btn {
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
font-size: 16px;
background: #17141a;
color: #fff;
cursor: pointer;
}
.pvr__deg {
min-width: 40px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
@media (max-width: 340px) {
.pvr__btn {
width: 28px;
height: 28px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvr__page, .pvr__frame {
transition: none;
}
}(function () {
// 기법: 버튼을 누른 횟수 × ROTATE_SNAP_DEG 로 목표 각도를 계산하고, 현재 각도를 매 프레임
// 그 목표로 보간(lerp)한다. 회전 중인 각도로 "회전된 사각형의 바운딩박스" 폭·높이를
// 매 프레임 다시 계산해 프레임 자체 크기를 바꿔가며 다시 그린다 — 90도를 넘길 때
// 가로세로가 뚝 끊겨 바뀌는 게 아니라 각도에 비례해 연속적으로 바뀐다.
var root = document.getElementById('pvrRoot');
var frame = document.getElementById('pvrFrame');
var page = document.getElementById('pvrPage');
var degEl = document.getElementById('pvrDeg');
var btnL = document.getElementById('pvrLeft');
var btnR = document.getElementById('pvrRight');
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
var ROTATE_SNAP_DEG = 90;
var LOOP_MS = 2000;
var PAGE_W = 88, PAGE_H = 70;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
(function paint() {
var rnd = seedRand(430061);
var html = '<div class="pvr__title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 5; i += 1) html += '<div class="pvr__line" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="pvr__fig"></div>';
page.innerHTML = html;
})();
function boxSize(angleDeg, w, h) {
var r = angleDeg * Math.PI / 180;
var c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
var target = 0, current = 0;
function render() {
var box = boxSize(current, PAGE_W, PAGE_H);
frame.style.width = box.w + 'px';
frame.style.height = box.h + 'px';
page.style.width = PAGE_W + 'px';
page.style.height = PAGE_H + 'px';
page.style.transform = 'translate(-50%,-50%) rotate(' + current.toFixed(1) + 'deg)';
degEl.textContent = Math.round(((current % 360) + 360) % 360) + '°';
}
function rotateBy(dir) { target += dir * ROTATE_SNAP_DEG; }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btnL.addEventListener('click', function () { markActive(); rotateBy(-1); });
btnR.addEventListener('click', function () { markActive(); rotateBy(1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === '[') { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === 'ArrowRight' || e.key === ']') { e.preventDefault(); markActive(); rotateBy(1); }
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; current = target = 0; render(); }
var fired = {};
var SCHEDULE = [
{ t: 150, run: function () { rotateBy(1); } },
{ t: 700, run: function () { rotateBy(1); } },
{ t: 1250, run: function () { rotateBy(-1); } },
{ t: 1650, run: function () { rotateBy(-1); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerRotateSnapControl.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerRotateSnapControl() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const degRef = useRef<HTMLSpanElement>(null);
const btnLRef = useRef<HTMLButtonElement>(null);
const btnRRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current, frame = frameRef.current, page = pageRef.current;
const degEl = degRef.current, btnL = btnLRef.current, btnR = btnRRef.current;
if (!root || !frame || !page || !degEl || !btnL || !btnR) return;
// 변형 06: ROTATE_SNAP_DEG 90 → 45 (전값이 소스 초기값)
const ROTATE_SNAP_DEG = 90;
const LOOP_MS = 2000;
const PAGE_W = 88, PAGE_H = 70;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430061);
let html = '<div class="' + styles.title + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 5; i += 1) html += '<div class="' + styles.line + '" style="width:' + (38 + rnd() * 54) + '%"></div>';
html += '<div class="' + styles.fig + '"></div>';
page.innerHTML = html;
function boxSize(angleDeg: number, w: number, h: number) {
const r = angleDeg * Math.PI / 180;
const c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
let target = 0, current = 0;
function render() {
const box = boxSize(current, PAGE_W, PAGE_H);
frame!.style.width = box.w + "px";
frame!.style.height = box.h + "px";
page!.style.width = PAGE_W + "px";
page!.style.height = PAGE_H + "px";
page!.style.transform = "translate(-50%,-50%) rotate(" + current.toFixed(1) + "deg)";
degEl!.textContent = Math.round(((current % 360) + 360) % 360) + "°";
}
function rotateBy(dir: number) { target += dir * ROTATE_SNAP_DEG; }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onLeftClick() { markActive(); rotateBy(-1); }
function onRightClick() { markActive(); rotateBy(1); }
btnL.addEventListener("click", onLeftClick);
btnR.addEventListener("click", onRightClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowLeft" || e.key === "[") { e.preventDefault(); markActive(); rotateBy(-1); }
else if (e.key === "ArrowRight" || e.key === "]") { e.preventDefault(); markActive(); rotateBy(1); }
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; current = target = 0; render(); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 150, run: () => { rotateBy(1); } },
{ t: 700, run: () => { rotateBy(1); } },
{ t: 1250, run: () => { rotateBy(-1); } },
{ t: 1650, run: () => { rotateBy(-1); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
current += (target - current) * 0.18;
render();
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btnL.removeEventListener("click", onLeftClick);
btnR.removeEventListener("click", onRightClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvr} is-demo`} ref={rootRef}>
<div className={styles.frame} ref={frameRef}>
<div className={styles.page} ref={pageRef} />
</div>
<div className={styles.controls}>
<button className={styles.btn} ref={btnLRef} type="button" aria-label="왼쪽으로 회전">↺</button>
<span className={styles.deg} ref={degRef}>0°</span>
<button className={styles.btn} ref={btnRRef} type="button" aria-label="오른쪽으로 회전">↻</button>
</div>
</div>
</main>
);
}06 · 90도 단위로 각도를 맞추는 회전 스냅 컨트롤 — 측정 누적면적 11.449% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function boxSize(angleDeg, w, h) {
var r = angleDeg * Math.PI / 180;
var c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
var target = 0, current = 0;
function render() {
var box = boxSize(current, PAGE_W, PAGE_H);
frame.style.width = box.w + 'px';
frame.style.height = box.h + 'px';
page.style.width = PAGE_W + 'px';
page.style.height = PAGE_H + 'px';
page.style.transform = 'translate(-50%,-50%) rotate(' + current.toFixed(1) + 'deg)';
degEl.textContent = Math.round(((current % 360) + 360) % 360) + '°';
}
function rotateBy(dir) { target += dir * ROTATE_SNAP_DEG; }
07남은 스크롤 거리를 계산해 부드럽게 이동하는 키보드 페이지 이동
방향키나 PageUp·PageDown 을 누르면 다음 또는 이전 페이지 상단까지 남은 거리를 계산해 스크롤 애니메이션 목표로 등록하고, 그 거리를 520ms 동안 감속 곡선으로 나눠 이동한다. 목표 인덱스가 배열 범위를 벗어나지 않게 매번 다시 계산해 첫 페이지에서 위로, 마지막 페이지에서 아래로 눌러도 튕기지 않는다. 07 항목은 누적면적 67.6%·강도 평균 45.8·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="pvk is-demo" id="pvkRoot" tabindex="0" aria-label="문서 보기, 방향키나 Page Up·Down 으로 페이지 이동">
<div class="pvk__doc" id="pvkDoc"></div>
<p class="pvk__badge" id="pvkBadge">1 / 5</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.pvk {
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;
outline: none;
}
.pvk__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pvk__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-3;
margin-bottom: $sp-3;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
min-height: 140px;
box-sizing: border-box;
}
.pvk__title { height: 9px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvk__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvk__fig { height: 34%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvk__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvk__page { min-height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.pvk__doc { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvk {
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;
outline: none;
}
.pvk__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvk__doc::-webkit-scrollbar {
display: none;
}
.pvk__page {
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
min-height: 140px;
box-sizing: border-box;
}
.pvk__title {
height: 9px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvk__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvk__fig {
height: 34%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvk__badge {
position: absolute;
right: 8px;
bottom: 8px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvk__page {
min-height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvk__doc {
scroll-behavior: auto;
}
}(function () {
// 기법: 현재 스크롤 위치에서 목표 페이지 상단까지 남은 거리를 구하고, 그 거리를
// 이징(ease-out) 곡선에 따라 여러 프레임으로 나눠 옮긴다. jump 나 scrollIntoView 처럼
// 한 번에 끊기지 않고, 감속하며 도착한다.
var root = document.getElementById('pvkRoot');
var doc = document.getElementById('pvkDoc');
var badge = document.getElementById('pvkBadge');
var LOOP_MS = 2000;
var N = 5;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430071);
var pageTops = [];
for (var i = 0; i < N; i += 1) {
var page = document.createElement('div');
page.className = 'pvk__page';
var html = '<div class="pvk__title" style="width:' + (44 + rnd() * 18) + '%"></div>';
for (var j = 0; j < 5; j += 1) html += '<div class="pvk__line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) html += '<div class="pvk__fig"></div>';
page.innerHTML = html;
doc.appendChild(page);
}
Array.prototype.forEach.call(doc.children, function (el) { pageTops.push(el.offsetTop); });
var currentIndex = 0, scrollAnim = null;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeScrollTo(target, dur, nowT) { scrollAnim = { from: doc.scrollTop, to: target, start: nowT, dur: dur }; }
function navigate(dir, nowT) {
var idx = Math.max(0, Math.min(N - 1, currentIndex + dir));
if (idx === currentIndex) return;
currentIndex = idx;
easeScrollTo(pageTops[idx], 520, nowT);
badge.textContent = (idx + 1) + ' / ' + N;
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'PageDown') { e.preventDefault(); markActive(); navigate(1, lastTs || 0); }
else if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); markActive(); navigate(-1, lastTs || 0); }
});
doc.addEventListener('wheel', function () { markActive(); }, { passive: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
var fired = {};
var SCHEDULE = [
{ t: 150, run: function (t) { navigate(1, t); } },
{ t: 650, run: function (t) { navigate(1, t); } },
{ t: 1150, run: function (t) { navigate(-1, t); } },
{ t: 1650, run: function (t) { navigate(-1, t); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (scrollAnim) {
var p = Math.max(0, Math.min(1, (ts - scrollAnim.start) / scrollAnim.dur));
doc.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerKeyboardPageEaseScroll.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerKeyboardPageEaseScroll() {
const rootRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, doc = docRef.current, badge = badgeRef.current;
if (!root || !doc || !badge) return;
const LOOP_MS = 2000;
const N = 5;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430071);
const pageTops: number[] = [];
for (let i = 0; i < N; i += 1) {
const page = document.createElement("div");
page.className = styles.page;
let html = '<div class="' + styles.title + '" style="width:' + (44 + rnd() * 18) + '%"></div>';
for (let j = 0; j < 5; j += 1) html += '<div class="' + styles.line + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) html += '<div class="' + styles.fig + '"></div>';
page.innerHTML = html;
doc.appendChild(page);
}
Array.prototype.forEach.call(doc.children, (el: HTMLElement) => { pageTops.push(el.offsetTop); });
let currentIndex = 0;
let scrollAnim: { from: number; to: number; start: number; dur: number } | null = null;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function easeScrollTo(target: number, dur: number, nowT: number) { scrollAnim = { from: doc!.scrollTop, to: target, start: nowT, dur }; }
function navigate(dir: number, nowT: number) {
const idx = Math.max(0, Math.min(N - 1, currentIndex + dir));
if (idx === currentIndex) return;
currentIndex = idx;
easeScrollTo(pageTops[idx], 520, nowT);
badge!.textContent = (idx + 1) + " / " + N;
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowDown" || e.key === "PageDown") { e.preventDefault(); markActive(); navigate(1, lastTs || 0); }
else if (e.key === "ArrowUp" || e.key === "PageUp") { e.preventDefault(); markActive(); navigate(-1, lastTs || 0); }
}
function onWheel() { markActive(); }
root.addEventListener("keydown", onKeydown as unknown as EventListener);
doc.addEventListener("wheel", onWheel, { passive: true });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 150, run: (t: number) => { navigate(1, t); } },
{ t: 650, run: (t: number) => { navigate(1, t); } },
{ t: 1150, run: (t: number) => { navigate(-1, t); } },
{ t: 1650, run: (t: number) => { navigate(-1, t); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (scrollAnim) {
const p = Math.max(0, Math.min(1, (ts - scrollAnim.start) / scrollAnim.dur));
doc!.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
doc.removeEventListener("wheel", onWheel);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvk} is-demo`} ref={rootRef} tabIndex={0} aria-label="문서 보기, 방향키나 Page Up·Down 으로 페이지 이동">
<div className={styles.doc} ref={docRef} />
<p className={styles.badge} ref={badgeRef}>1 / 5</p>
</div>
</main>
);
}07 · 남은 스크롤 거리를 계산해 부드럽게 이동하는 키보드 페이지 이동 — 측정 누적면적 67.574% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var currentIndex = 0, scrollAnim = null;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeScrollTo(target, dur, nowT) { scrollAnim = { from: doc.scrollTop, to: target, start: nowT, dur: dur }; }
function navigate(dir, nowT) {
var idx = Math.max(0, Math.min(N - 1, currentIndex + dir));
if (idx === currentIndex) return;
currentIndex = idx;
easeScrollTo(pageTops[idx], 520, nowT);
badge.textContent = (idx + 1) + ' / ' + N;
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'PageDown') { e.preventDefault(); markActive(); navigate(1, lastTs || 0); }
else if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); markActive(); navigate(-1, lastTs || 0); }
});
doc.addEventListener('wheel', function () { markActive(); }, { passive: true });
08받은 바이트 비율만큼 채워지는 다운로드 진행 링
다운로드가 시작되면 지금까지 받은 바이트 수를 전체 크기로 나눠 원형 테두리 둘레(CIRC) 중 채울 길이를 계산하고, 그 값을 stroke-dashoffset 에 넘겨 링이 그 비율만큼 차오르게 만든다. 100%에 닿으면 단계가 done 으로 바뀌면서 미리 길이를 계산해 둔 체크 표시 선이 그려지는 애니메이션으로 넘어간다. 08 항목은 누적면적 2.7%·강도 평균 132.2·23프레임 중 22프레임에서 움직였다 — 링과 버튼 문구만 바뀌는 항목이라 면적은 작다.

<main class="stage">
<div class="pvg is-demo" id="pvgRoot" tabindex="0">
<svg class="pvg__ring" viewBox="0 0 92 92" width="92" height="92" aria-hidden="true">
<circle class="pvg__track" cx="46" cy="46" r="36"></circle>
<circle class="pvg__fill" id="pvgFill" cx="46" cy="46" r="36" transform="rotate(-90 46 46)"></circle>
<line class="pvg__check" id="pvgCheckA" x1="28" y1="52" x2="40" y2="64"></line>
<line class="pvg__check" id="pvgCheckB" x1="28" y1="52" x2="64" y2="30"></line>
</svg>
<p class="pvg__pct" id="pvgPct">0%</p>
<button class="pvg__btn" id="pvgBtn" type="button">다운로드</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pvg__ring { display: block; }
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, .12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: $subject-blue;
stroke-width: 7;
stroke-linecap: round;
transition: opacity $dur-base ease;
}
.pvg__check {
stroke: $subject-blue-deep;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__fill { opacity: 0; }
.pvg--done .pvg__check { opacity: 1; }
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: $subject-ink;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__pct { opacity: 0; }
.pvg__btn {
border: none;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-size: 12px;
font-weight: 700;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct { transition: none; }
}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);
}
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pvg__ring {
display: block;
}
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 7;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pvg__check {
stroke: #0a4fe8;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pvg--done .pvg__fill {
opacity: 0;
}
.pvg--done .pvg__check {
opacity: 1;
}
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #17141a;
transition: opacity 300ms ease;
}
.pvg--done .pvg__pct {
opacity: 0;
}
.pvg__btn {
border: none;
border-radius: 999px;
padding: 8px 20px;
font-size: 12px;
font-weight: 700;
background: #17141a;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct {
transition: none;
}
}(function () {
// 기법: 받은 바이트 / 전체 바이트로 링의 채움 비율을 계산해 stroke-dashoffset 으로
// 옮긴다. 100%에 닿으면 링을 지우고 같은 시작점(28,52)에서 뻗어 나온 두 선분의
// stroke-dashoffset 을 0 으로 당겨 체크 아이콘을 그린다.
var root = document.getElementById('pvgRoot');
var fill = document.getElementById('pvgFill');
var pctEl = document.getElementById('pvgPct');
var btn = document.getElementById('pvgBtn');
var checkA = document.getElementById('pvgCheckA');
var checkB = document.getElementById('pvgCheckB');
var R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) {
var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2');
return Math.hypot(x2 - x1, y2 - y1);
}
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
var TOTAL_BYTES = 260000;
var SPEED = 230000;
var LOOP_MS = 2000;
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
function render(ratio) {
fill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl.textContent = Math.round(ratio * 100) + '%';
}
function startDownload(nowT) {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pvg--done');
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
btn.textContent = '받는 중';
render(0);
}
function finish(nowT) {
phase = 'done'; doneAt = nowT;
root.classList.add('pvg--done');
btn.textContent = '완료';
checkAnim = { start: nowT };
}
function resetIdle() {
phase = 'idle'; received = 0;
root.classList.remove('pvg--done');
btn.textContent = '다운로드';
render(0);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btn.addEventListener('click', function (e) { markActive(); if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault(); markActive();
if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); }
} else if (e.key === 'Escape') {
e.preventDefault(); markActive(); resetIdle();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; render(0.5); }
var fired = {};
var SCHEDULE = [
{ t: 100, run: function (t) { if (phase === 'idle') startDownload(t); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (phase === 'loading') {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
var ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
requestAnimationFrame(tick);
}
render(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerDownloadProgressRing.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerDownloadProgressRing() {
const rootRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<SVGCircleElement>(null);
const pctRef = useRef<HTMLParagraphElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, fill = fillRef.current, pctEl = pctRef.current;
const btn = btnRef.current, checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !fill || !pctEl || !btn || !checkA || !checkB) return;
const R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
const TOTAL_BYTES = 260000;
const SPEED = 230000;
const LOOP_MS = 2000;
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
function render(ratio: number) {
fill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl!.textContent = Math.round(ratio * 100) + "%";
}
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2);
checkB!.style.strokeDashoffset = lenB.toFixed(2);
btn!.textContent = "받는 중";
render(0);
}
function finish(nowT: number) {
phase = "done"; doneAt = nowT;
root!.classList.add(styles.done);
btn!.textContent = "완료";
checkAnim = { start: nowT };
}
function resetIdle() {
phase = "idle"; received = 0;
root!.classList.remove(styles.done);
btn!.textContent = "다운로드";
render(0);
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onClick() { markActive(); if (phase !== "loading") { resetIdle(); startDownload(); } }
btn.addEventListener("click", onClick);
function onKeydown(e: KeyEvt) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault(); markActive();
if (phase !== "loading") { resetIdle(); startDownload(); }
} else if (e.key === "Escape") {
e.preventDefault(); markActive(); resetIdle();
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; render(0.5); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 100, run: () => { if (phase === "idle") startDownload(); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
if (phase === "loading") {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
const ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
raf = requestAnimationFrame(tick);
}
render(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvg} is-demo`} ref={rootRef} tabIndex={0}>
<svg className={styles.ring} viewBox="0 0 92 92" width={92} height={92} aria-hidden="true">
<circle className={styles.track} cx={46} cy={46} r={36} />
<circle className={styles.fill} ref={fillRef} cx={46} cy={46} r={36} transform="rotate(-90 46 46)" />
<line className={styles.check} ref={checkARef} x1={28} y1={52} x2={40} y2={64} />
<line className={styles.check} ref={checkBRef} x1={28} y1={52} x2={64} y2={30} />
</svg>
<p className={styles.pct} ref={pctRef}>0%</p>
<button className={styles.btn} ref={btnRef} type="button">다운로드</button>
</div>
</main>
);
}08 · 받은 바이트 비율만큼 채워지는 다운로드 진행 링 — 측정 누적면적 2.737% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function startDownload(nowT) {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pvg--done');
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
btn.textContent = '받는 중';
render(0);
}
function finish(nowT) {
phase = 'done'; doneAt = nowT;
root.classList.add('pvg--done');
btn.textContent = '완료';
checkAnim = { start: nowT };
}
function resetIdle() {
phase = 'idle'; received = 0;
root.classList.remove('pvg--done');
btn.textContent = '다운로드';
render(0);
}
09여러 계산을 한 툴바에 모은 문서 뷰어 히어로 장면
01 의 스크롤 비율 계산, 02 의 포인터 초점 줌, 08 의 바이트 진행 링을 한 툴바 위에 동시에 올려 문서를 훑고 확대하고 내려받는 장면을 완성한다. 스크롤이 바뀌면 가장 많이 보이는 페이지를 다시 찾아 썸네일을 강조하고, 그 계산은 01 과 같은 겹침 비율 방식을 그대로 쓴다. 09 항목은 누적면적 40.1%·강도 평균 46.2·23프레임 중 21프레임에서 움직였다.

<main class="stage">
<div class="pht is-demo" id="phtRoot" tabindex="0" aria-label="문서 훑기·확대·다운로드가 함께 있는 장면, 방향키로 페이지 이동·+/- 로 확대">
<div class="pht__toolbar">
<div class="pht__rail" id="phtRail">
<div class="pht__railTrack" id="phtRailTrack"></div>
</div>
<button class="pht__ringBtn" id="phtRingBtn" type="button" aria-label="다운로드">
<svg class="pht__ring" viewBox="0 0 40 40" width="26" height="26" aria-hidden="true">
<circle class="pht__ringTrack" cx="20" cy="20" r="15"></circle>
<circle class="pht__ringFill" id="phtRingFill" cx="20" cy="20" r="15" transform="rotate(-90 20 20)"></circle>
<line class="pht__ringCheck" id="phtCheckA" x1="12" y1="21" x2="17" y2="27"></line>
<line class="pht__ringCheck" id="phtCheckB" x1="12" y1="21" x2="28" y2="12"></line>
</svg>
</button>
</div>
<div class="pht__stage" id="phtStage">
<div class="pht__doc" id="phtDoc">
<div class="pht__docInner" id="phtDocInner"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pht {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
background: $subject-ink;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: $sp-2;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: $r-sm;
background: $subject-cream;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
&--active { border-color: $subject-blue-light; }
}
.pht__miniTitle { height: 4px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: 4px; }
.pht__miniLine { height: 2px; border-radius: $r-xs; background: rgba(23, 20, 26, .22); margin-top: 4px; }
.pht__miniFig { height: 28%; margin-top: 4px; border-radius: $r-xs; background: rgba(47, 109, 246, .22); }
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .12);
cursor: pointer;
}
.pht__ring { display: block; }
.pht__ringTrack { fill: none; stroke: rgba(255, 255, 255, .25); stroke-width: 4; }
.pht__ringFill { fill: none; stroke: $subject-blue-light; stroke-width: 4; stroke-linecap: round; transition: opacity $dur-base ease; }
.pht__ringCheck { stroke: #fff; stroke-width: 4; stroke-linecap: round; fill: none; opacity: 0; transition: opacity $dur-base ease; }
.pht--done .pht__ringFill { opacity: 0; }
.pht--done .pht__ringCheck { opacity: 1; }
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pht__docInner { will-change: transform; }
.pht__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-3;
margin-bottom: $sp-3;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
min-height: 120px;
box-sizing: border-box;
}
.pht__docTitle { height: 8px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pht__docLine { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pht__docFig { height: 30%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
@media (max-width: 340px) {
.pht__thumb { width: 32px; height: 42px; }
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pht {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #17141a;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: 8px;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: 8px;
background: #fff7e6;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
}
.pht__thumb--active {
border-color: #5b8cf8;
}
.pht__miniTitle {
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 4px;
}
.pht__miniLine {
height: 2px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.22);
margin-top: 4px;
}
.pht__miniFig {
height: 28%;
margin-top: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.22);
}
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.12);
cursor: pointer;
}
.pht__ring {
display: block;
}
.pht__ringTrack {
fill: none;
stroke: rgba(255, 255, 255, 0.25);
stroke-width: 4;
}
.pht__ringFill {
fill: none;
stroke: #5b8cf8;
stroke-width: 4;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pht__ringCheck {
stroke: #fff;
stroke-width: 4;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pht--done .pht__ringFill {
opacity: 0;
}
.pht--done .pht__ringCheck {
opacity: 1;
}
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pht__doc::-webkit-scrollbar {
display: none;
}
.pht__docInner {
will-change: transform;
}
.pht__page {
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
min-height: 120px;
box-sizing: border-box;
}
.pht__docTitle {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pht__docLine {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pht__docFig {
height: 30%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
@media (max-width: 340px) {
.pht__thumb {
width: 32px;
height: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck {
transition: none;
}
}(function () {
// 09 = 01(썸네일 레일 동기)+02(포인터 초점 줌)+08(다운로드 링) 결합. 각 장면의 계산을
// 그대로 가져와 한 무대에 얹는다: 스크롤 겹침 비율로 레일을 맞추고, 포인터 좌표비로
// 확대 원점을 계산하고, 받은 바이트 비율로 링을 채운다.
var root = document.getElementById('phtRoot');
var rail = document.getElementById('phtRail');
var railTrack = document.getElementById('phtRailTrack');
var stage = document.getElementById('phtStage');
var doc = document.getElementById('phtDoc');
var docInner = document.getElementById('phtDocInner');
var ringBtn = document.getElementById('phtRingBtn');
var ringFill = document.getElementById('phtRingFill');
var checkA = document.getElementById('phtCheckA');
var checkB = document.getElementById('phtCheckB');
var HERO_ZOOM_STEP = 1.2;
var HERO_RAIL_LERP = 0.18;
var HERO_TOTAL_BYTES = 200000;
var HERO_SPEED = 230000;
var LOOP_MS = 2000;
var N = 4;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430091);
function pageHTML(prefix) {
var h = '<div class="' + prefix + 'Title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 4; i += 1) h += '<div class="' + prefix + 'Line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + prefix + 'Fig"></div>';
return h;
}
for (var p = 0; p < N; p += 1) {
var page = document.createElement('div');
page.className = 'pht__page';
page.innerHTML = pageHTML('pht__doc');
docInner.appendChild(page);
var thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'pht__thumb';
thumb.setAttribute('aria-label', '페이지 ' + (p + 1) + '로 이동');
thumb.innerHTML = pageHTML('pht__mini');
railTrack.appendChild(thumb);
}
var pages = Array.prototype.slice.call(docInner.children);
var thumbs = Array.prototype.slice.call(railTrack.children);
function computeCurrent() {
var top = doc.scrollTop, h = doc.clientHeight, best = 0, bestRatio = -1;
pages.forEach(function (pg, i) {
var pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
var visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
var ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
var activeIdx = -1;
function markRail(idx) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach(function (t, i) { t.classList.toggle('pht__thumb--active', i === idx); });
}
var railX = 0;
function railTarget(idx) {
var t = thumbs[idx]; if (!t) return 0;
var want = t.offsetLeft + t.offsetWidth / 2 - rail.clientWidth / 2;
return -Math.max(0, Math.min(railTrack.scrollWidth - rail.clientWidth, want));
}
var zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale, ox, oy) {
docInner.style.transformOrigin = (ox * 100) + '% ' + (oy * 100) + '%';
docInner.style.transform = 'scale(' + scale.toFixed(3) + ')';
}
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
var R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) { var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2'); return Math.hypot(x2 - x1, y2 - y1); }
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio) { ringFill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pht--done');
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT) { phase = 'done'; doneAt = nowT; root.classList.add('pht--done'); checkAnim = { start: nowT }; }
function resetDownload() { phase = 'idle'; received = 0; root.classList.remove('pht--done'); renderRing(0); }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
doc.addEventListener('scroll', function () { markRail(computeCurrent()); });
stage.addEventListener('wheel', function (e) {
e.preventDefault(); markActive();
var r = stage.getBoundingClientRect();
var ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}, { passive: false });
thumbs.forEach(function (t, i) {
t.addEventListener('click', function () {
markActive(); markRail(i);
doc.scrollTop = pages[i].offsetTop;
});
});
ringBtn.addEventListener('click', function () { markActive(); if (phase !== 'loading') { resetDownload(); startDownload(); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); markActive();
var next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === 'ArrowDown' ? 1 : -1)));
doc.scrollTop = pages[next].offsetTop;
} else if (e.key === '+' || e.key === '=' || e.key === '-') {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === '-' ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
var fired = {};
var autoScale = { s: 1 };
var SCHEDULE = [
{ t: 0, run: function () { doc.scrollTop = 0; } },
{ t: 200, run: function () { doc.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: function () { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: function () { autoScale.target = 1; } },
{ t: 1050, run: function () { doc.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: function () { if (phase === 'idle') startDownload(); } },
{ t: 1750, run: function () { doc.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack.style.transform = 'translateX(' + railX.toFixed(1) + 'px)';
if (phase === 'loading') {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
var ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
requestAnimationFrame(tick);
}
markRail(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerHeroToolbarScene.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type WheelEvt = { deltaY: number; clientX: number; clientY: number; preventDefault(): void };
export default function PdfViewerHeroToolbarScene() {
const rootRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const railTrackRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const docInnerRef = useRef<HTMLDivElement>(null);
const ringBtnRef = useRef<HTMLButtonElement>(null);
const ringFillRef = useRef<SVGCircleElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, rail = railRef.current, railTrack = railTrackRef.current;
const stage = stageRef.current, doc = docRef.current, docInner = docInnerRef.current;
const ringBtn = ringBtnRef.current, ringFill = ringFillRef.current;
const checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !rail || !railTrack || !stage || !doc || !docInner || !ringBtn || !ringFill || !checkA || !checkB) return;
const HERO_ZOOM_STEP = 1.2;
const HERO_RAIL_LERP = 0.18;
const HERO_TOTAL_BYTES = 200000;
const HERO_SPEED = 230000;
const LOOP_MS = 2000;
const N = 4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430091);
function pageHTML(titleCls: string, lineCls: string, figCls: string) {
let h = '<div class="' + titleCls + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 4; i += 1) h += '<div class="' + lineCls + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + figCls + '"></div>';
return h;
}
for (let p = 0; p < N; p += 1) {
const page = document.createElement("div");
page.className = styles.page;
page.innerHTML = pageHTML(styles.docTitle, styles.docLine, styles.docFig);
docInner.appendChild(page);
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = styles.thumb;
thumb.setAttribute("aria-label", "페이지 " + (p + 1) + "로 이동");
thumb.innerHTML = pageHTML(styles.miniTitle, styles.miniLine, styles.miniFig);
railTrack.appendChild(thumb);
}
const pages = Array.prototype.slice.call(docInner.children) as HTMLElement[];
const thumbs = Array.prototype.slice.call(railTrack.children) as HTMLElement[];
function computeCurrent() {
const top = doc!.scrollTop, h = doc!.clientHeight;
let best = 0, bestRatio = -1;
pages.forEach((pg, i) => {
const pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
const visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
const ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
let activeIdx = -1;
function markRail(idx: number) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach((t, i) => { t.classList.toggle(styles.thumbActive, i === idx); });
}
let railX = 0;
function railTarget(idx: number) {
const t = thumbs[idx]; if (!t) return 0;
const want = t.offsetLeft + t.offsetWidth / 2 - rail!.clientWidth / 2;
return -Math.max(0, Math.min(railTrack!.scrollWidth - rail!.clientWidth, want));
}
const zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale: number, ox: number, oy: number) {
docInner!.style.transformOrigin = (ox * 100) + "% " + (oy * 100) + "%";
docInner!.style.transform = "scale(" + scale.toFixed(3) + ")";
}
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
const R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio: number) { ringFill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2); checkB!.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT: number) { phase = "done"; doneAt = nowT; root!.classList.add(styles.done); checkAnim = { start: nowT }; }
function resetDownload() { phase = "idle"; received = 0; root!.classList.remove(styles.done); renderRing(0); }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onScroll() { markRail(computeCurrent()); }
function onWheel(e: WheelEvt) {
e.preventDefault(); markActive();
const r = stage!.getBoundingClientRect();
const ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}
doc.addEventListener("scroll", onScroll);
stage.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
const thumbHandlers: Array<() => void> = [];
thumbs.forEach((t, i) => {
const h = () => { markActive(); markRail(i); doc!.scrollTop = pages[i].offsetTop; };
thumbHandlers.push(h);
t.addEventListener("click", h);
});
function onRingClick() { markActive(); if (phase !== "loading") { resetDownload(); startDownload(); } }
ringBtn.addEventListener("click", onRingClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); markActive();
const next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === "ArrowDown" ? 1 : -1)));
doc!.scrollTop = pages[next].offsetTop;
} else if (e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === "-" ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
const fired: Record<string, boolean> = {};
const autoScale: { target?: number; ox?: number; oy?: number } = {};
const SCHEDULE = [
{ t: 0, run: () => { doc!.scrollTop = 0; } },
{ t: 200, run: () => { doc!.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: () => { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: () => { autoScale.target = 1; } },
{ t: 1050, run: () => { doc!.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: () => { if (phase === "idle") startDownload(); } },
{ t: 1750, run: () => { doc!.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack!.style.transform = "translateX(" + railX.toFixed(1) + "px)";
if (phase === "loading") {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
const ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
raf = requestAnimationFrame(tick);
}
markRail(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
doc.removeEventListener("scroll", onScroll);
stage.removeEventListener("wheel", onWheel as unknown as EventListener);
thumbs.forEach((t, i) => t.removeEventListener("click", thumbHandlers[i]));
ringBtn.removeEventListener("click", onRingClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pht} is-demo`} ref={rootRef} tabIndex={0} aria-label="문서 훑기·확대·다운로드가 함께 있는 장면, 방향키로 페이지 이동·+/- 로 확대">
<div className={styles.toolbar}>
<div className={styles.rail} ref={railRef}>
<div className={styles.railTrack} ref={railTrackRef} />
</div>
<button className={styles.ringBtn} ref={ringBtnRef} type="button" aria-label="다운로드">
<svg className={styles.ring} viewBox="0 0 40 40" width={26} height={26} aria-hidden="true">
<circle className={styles.ringTrack} cx={20} cy={20} r={15} />
<circle className={styles.ringFill} ref={ringFillRef} cx={20} cy={20} r={15} transform="rotate(-90 20 20)" />
<line className={styles.ringCheck} ref={checkARef} x1={12} y1={21} x2={17} y2={27} />
<line className={styles.ringCheck} ref={checkBRef} x1={12} y1={21} x2={28} y2={12} />
</svg>
</button>
</div>
<div className={styles.stageArea} ref={stageRef}>
<div className={styles.doc} ref={docRef}>
<div className={styles.docInner} ref={docInnerRef} />
</div>
</div>
</div>
</main>
);
}09 · 여러 계산을 한 툴바에 모은 문서 뷰어 히어로 장면 — 측정 누적면적 40.053% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function computeCurrent() {
var top = doc.scrollTop, h = doc.clientHeight, best = 0, bestRatio = -1;
pages.forEach(function (pg, i) {
var pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
var visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
var ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
var activeIdx = -1;
function markRail(idx) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach(function (t, i) { t.classList.toggle('pht__thumb--active', i === idx); });
}
var railX = 0;
function railTarget(idx) {
var t = thumbs[idx]; if (!t) return 0;
var want = t.offsetLeft + t.offsetWidth / 2 - rail.clientWidth / 2;
return -Math.max(0, Math.min(railTrack.scrollWidth - rail.clientWidth, want));
}
값이 반응을 바꾸는 곳 — 줌 초점 배율·스와이프 플립 속도·회전 스냅 각도
pdf 미리보기 프로그램마다 스크롤·확대·회전 감도가 다르게 느껴지는 이유는 화면에 보이는 요소 개수가 아니라 이 표의 세 수치 차이다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 썸네일 레일 동기화 | 59.6% | 39.5 | 23/23 |
| 02 포인터 초점 줌 | 57.5% | 93.4 | 23/23 |
| 03 검색 하이라이트 점프 | 40.3% | 67.8 | 22/23 |
| 04 스와이프 플립 | 42.2% | 27.2 | 10/23 |
| 05 프로그레시브 로드 바 | 2.8% | 114.3 | 22/23 |
| 06 회전 스냅 컨트롤 | 11.4% | 96.2 | 23/23 |
| 07 키보드 페이지 이동 | 67.6% | 45.8 | 23/23 |
| 08 다운로드 진행 링 | 2.7% | 132.2 | 22/23 |
| 09 툴바 히어로 장면 | 40.1% | 46.2 | 21/23 |
ZOOM_FOCAL_STEP_RATIO 는 휠 한 번에 확대 배율이 몇 배씩 뛰는지를, SWIPE_FLIP_VELOCITY_PX_S 는 손을 얼마나 빨리 밀어야 다음 장으로 넘어가는지를, ROTATE_SNAP_DEG 는 버튼 한 번에 몇 도씩 회전하는지를 정한다. 아래 변형 표에서 이 세 상수를 직접 바꿔 감이 달라지는 걸 확인할 수 있다.
어디서 깨지는가 — 터치 스크롤 충돌·큰 파일 로딩 지연·키보드 대안 경로
01·07·09 처럼 스크롤 이벤트로 현재 페이지를 계산하는 항목은 모바일 브라우저의 관성 스크롤과 겹치면 scroll 이벤트가 한 프레임에 여러 번 몰려 들어와 계산이 튀는 순간이 있다 — 실무에서는 requestAnimationFrame 으로 계산 시점을 한 프레임에 한 번으로 묶어야 한다. 05·08 처럼 바이트 진행률을 계산하는 항목은 이 데모에서는 결정적 곡선으로 시뮬레이션했지만, 실제로 큰 파일을 받을 때는 속도가 순간적으로 0에 가까워지는 구간이 있어 막대가 한동안 멈춘 것처럼 보일 수 있다 — pdf 미리보기 오류로 오해받는 지점이 대개 여기다. 04 는 손을 뗄 때의 속도만 보고 방향을 정하는데, 짧고 빠르게 두 번 미는 동작에서는 첫 번째 스와이프가 끝나기 전에 두 번째 입력이 들어와 속도 계산이 뒤섞일 수 있다.
이 뷰어를 실무에 붙일 때는 이런 스크롤·네트워크 경합까지 같이 신경 써야 틈이 줄어든다. 아홉 개 원본이 들어 있는 압축 파일을 풀 때 묻는 비밀번호는 hat2hhry 이다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 02 | ZOOM_FOCAL_STEP_RATIO | 1.2 → 2.4 | 값을 줄이면 휠 한 번에 조금씩만 확대되고, 값을 늘리면 같은 휠 동작에도 훨씬 크게 뛰어 확대된다 |
| 04 | SWIPE_FLIP_VELOCITY_PX_S | 300 → 600 | 값을 줄이면 살짝만 밀어도 다음 페이지로 넘어가고, 값을 늘리면 꽤 빠르게 밀어야만 넘어간다 |
| 06 | ROTATE_SNAP_DEG | 90 → 45 | 값을 늘리면 버튼 한 번에 큰 각도로 회전하고, 값을 줄이면 같은 버튼으로 더 잘게 나눠 회전한다 |
아래 설명은 각 항목 소스 안에 이미 들어 있는 분기 코드를 그대로 옮긴 것뿐, 별도로 덧붙인 처리는 없다. prefers-reduced-motion: reduce 분기는 matchMedia 로 아홉 항목 전부에 있어, 동작 줄임 설정을 켠 방문자에게는 자동 시연 없이 목표 상태(마지막 페이지 근처 스크롤·기본 배율·완료된 진행 바)로 바로 건너뛴다. 키보드 경로도 아홉 항목 전부에 keydown 리스너가 있다 — 01 은 방향키와 PageUp·PageDown, 07 도 같은 방향키·페이지 키로 페이지를 넘기고, 08 은 Enter·Space 로 다운로드를 시작하고 Escape 로 취소한다. 02 의 확대·06 의 회전도 각각 +·-, [·] 키로 같은 결과를 얻을 수 있다.
pdf 뷰어 ui는 라이브러리 없이 스크롤과 확대를 함께 계산할 수 있는가?
가능하다 — 이 아홉 항목 전부가 requestAnimationFrame 루프 하나와 좌표·비율 계산만으로 스크롤·확대·회전·진행률을 서로 맞춰 돌린다. 문서 협업 툴이나 계약서 열람 화면처럼 여러 기능을 한 화면에 붙여야 한다면 09 의 툴바 구성처럼 01(탐색)·02(확인)·08(내려받기) 세 계산만 겹쳐도 뷰어 하나의 완성 장면이 된다. 위젯 형태로 문서를 보여줘야 하는 자리라면 블로그 위젯 디자인 9종 — 공유·후기·지도 임베드 카드 의 카드 레이아웃과 같이 붙이면 되고, 확대 동작만 따로 떼어 다른 이미지에도 쓰고 싶다면 이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어 쪽 초점 계산과 비교해 보는 편이 낫다. 재생 진행 바처럼 시간축을 다루는 화면이라면 동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다 의 진행률 계산과도 구조가 닮았다.
pdf 미리보기 구현에서 자주 나오는 질문
pdf 미리보기 스크롤이 자연스럽지 않고 뚝뚝 끊기면 뭘 의심해야 하나요?
01·07·09 는 스크롤·이동 값을 매 프레임 목표치의 일정 비율만큼만 좁혀가며 계산하는데, 이 비율 계산을 건너뛰고 값을 한 번에 바꾸면 중간 단계 없이 뚝 끊긴 것처럼 보인다. requestAnimationFrame 루프 밖에서 값을 직접 대입하는 코드가 있는지부터 확인하는 편이 낫다.
다른 문서 뷰어 프로그램에도 이 아홉 가지를 그대로 적용할 수 있나요?
여기서 계산하는 건 스크롤 비율·포인터 좌표·속도·바이트 수처럼 어느 문서 뷰어에도 있는 값이라, 실제 문서를 그리는 방식과 무관하게 페이지 계산·확대·진행률 표시 로직만 떼어 붙일 수 있다.
이 아홉 개를 하나의 뷰어에 전부 같이 써도 되나요?
다 붙이는 것보다는 09 정도로 두세 항목만 골라 겹치는 편이 화면 계산량 면에서 안전하다. 스크롤 동기화·초점 줌·회전 재계산이 각자 프레임마다 좌표를 다시 구하는 구조라, 한 화면에 다 올리면 그 계산이 겹겹이 쌓인다.









