스크롤 인터랙션 9종 — 선 긋기·길 따라가기·꼬리·나선
굽은 길 위의 스크롤 인터랙션은 빠르기부터 어긋난다. 스크롤 진행률을 그대로 곡선 변수에 넣으면 01번 펜 끝의 빠르기가 굽이마다 6.2배까지 벌어진다.
무대 안에서 굴려 보는 스크롤 진행률 아홉 장면
01 spa-arc-length-even-line-draw
02 spa-tangent-turn-paper-plane
03 spa-milestone-pin-spring-pop
04 spa-velocity-wobble-stroke
05 spa-helix-perspective-depth-sort
06 spa-chain-tail-length-constraint
07 spa-shape-morph-arc-resample
08 spa-flow-dots-speed-gain
09 spa-delivery-route-journey-hero
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 굽은 길도 같은 빠르기로 그어지는 선
- 02 길의 접선 방향으로 고개를 돌리며 나는 종이비행기
- 03 지나는 순간 스프링으로 솟는 길 위 이정표
- 04 빨리 굴릴수록 굵어지고 출렁이는 선
- 05 나선 계단을 돌아 내려가는 공과 원근
- 06 마디 길이를 지키며 길을 따라 끌려오는 꼬리
- 07 같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선
- 08 스크롤 빠르기만큼 길 위를 흘러가는 점 무리
- 09 지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정
손대지 않은 칸은 시드를 고정한 왕복 시간표를 따라 저절로 굴러간다. 한 바퀴 5,400ms 는 200ms 기다림, 2,600ms 앞으로 가기, 500ms 끝 대기, 1,800ms 되돌아가기, 300ms 쉼으로 짜였고, 앞으로 가는 구간은 네다섯 토막으로 나뉘어 토막마다 나아가는 몫과 걸리는 시간이 달라서 빠르기가 계속 바뀐다. 칸 위에서 휠을 굴리거나 칸을 누르거나 Tab 으로 초점을 옮기면 시연이 그 자리에서 멈추고, 그때의 진행률에서부터 사람이 이어 굴린다.
순서는 길이 표를 만드는 01 에서 시작한다. 02 는 그 표에서 접선을, 03 은 이정표의 길이를, 04 는 법선과 빠르기를 꺼내 쓰고, 05 는 표 없이 3차원 나선으로 한 번 비켜 간다. 06 은 다시 표 위의 머리에 꼬리를 매달고, 07 은 곡선 대신 두 외곽선의 둘레를 재며, 08 은 점 160개가 한 표를 나눠 쓰고, 09 는 01·02·03 을 배송 경로 하나에 묶는다.
스크롤 인터랙션 9종 — 고른 선 긋기·접선 따라 도는 비행기·이정표 핀·빠르기 출렁임·나선 투영·끌려오는 꼬리·모양 바꾸기·흐르는 점·배송 추적 여정
아홉 장면은 전부 캔버스 2D 위에 그려지고, CSS 쪽 @keyframes 는 0개다. 요소가 화면에 들어오는 순간 나타나게 하는 스크롤 애니메이션 효과와 달리, 여기서는 진행률이라는 숫자 하나를 곡선 위 길이로 바꾸고 그 길이에서 점·접선·법선을 꺼내 움직임을 만든다. 움직임은 requestAnimationFrame 이 넘겨 주는 시간을 16ms 고정 스텝으로 쪼개 한 프레임에 최대 6번 계산하고, 프레임 사이가 길어도 100ms 까지만 따라잡는다.
캔버스 위에 투명한 스크롤 상자를 겹친 구조
아홉 칸의 생김새는 같다. 그림은 aria-hidden="true" 캔버스가 맡고, 그 위에 .sp__scroller 상자가 position: absolute; inset: 0 으로 투명하게 겹쳐 휠과 손가락과 키보드를 받는다. 상자 안의 빈 .sp__track 은 height: 600% 라서 상자 높이의 다섯 배를 굴리면 진행률이 0 에서 1 로 간다. 진행률은 아래처럼 상자 자신의 scrollTop 만 읽는다.
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
480×300 창에서 무대는 418.6×261.6px 이고 굴릴 수 있는 거리는 그 높이의 다섯 배인 1,307px 였다. 휠을 300px 한 번 굴리면 진행률이 약 23%p 오르고, 그동안 window.scrollY 는 0 그대로였다. 시연 중에는 코드가 상자도 같은 진행률로 굴려 두어 스크롤 막대가 함께 움직이고, 코드가 굴린 자리는 expectTop 에 적어 두어 사람의 입력으로 착각하지 않는다.
2초 동안 바뀐 화면을 23번 세어 본 결과
캡처 24장 사이의 변화 23번 가운데 화면이 바뀐 횟수를 프레임 칸에, 바뀐 화소가 무대에서 차지한 넓이를 누적면적 칸에 적었다.
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 고른 선 긋기 | 18.6% | 21/23 | 82.7 |
| 02 접선 따라 도는 비행기 | 11.3% | 23/23 | 112.1 |
| 03 이정표 핀 | 15.1% | 23/23 | 97.0 |
| 04 빠르기 출렁임 | 17.9% | 23/23 | 80.3 |
| 05 나선 투영 | 47.7% | 23/23 | 119.1 |
| 06 끌려오는 꼬리 | 22.1% | 23/23 | 142.7 |
| 07 모양 바꾸기 | 25.6% | 23/23 | 106.7 |
| 08 흐르는 점 | 24.6% | 23/23 | 127.8 |
| 09 배송 추적 여정 | 12.9% | 23/23 | 120.4 |
05 의 47.7% 가 유난히 큰 것은 계단 판이 무대 가운데를 크게 채운 채 카메라가 공을 따라 내려가 판 전체가 위로 밀려 올라가기 때문으로 보인다. 02 와 09 는 선 하나와 비행기, 또는 상자 하나가 지나가는 자리만 바뀌어 11~13% 에 머무는 것으로 보인다. 01 만 21/23 인 것은 선이 진행 6% 를 넘어야 긋기 시작하는데 그 앞에 200ms 기다림까지 겹쳐 처음 몇 장이 같은 화면이었던 탓으로 보인다.
서비스 소개 페이지부터 배송 조회까지, 이런 화면에 붙인다
장면마다 실제 페이지의 자리를 하나씩 맡겼다. 01 은 서비스 소개 페이지를 따라 내려가는 흐름선, 02 는 문의하기 섹션으로 시선을 데려가는 장식, 03 은 회사 연혁 타임라인, 04 는 랜딩 페이지의 구간 구분선이다. 05 는 앱 기능을 단계별로 보여 주는 소개, 06 은 브랜드 마스코트가 길을 안내하는 장면, 07 은 요금제 비교 구간의 아이콘 전환, 08 은 데이터가 오가는 흐름을 설명하는 도식이다.
09 는 주문 배송 조회 화면을 흉내 낸 가상 장면이다. 지명이나 주소는 없고 정거장은 「출발지·정거장 1·정거장 2·도착지」 네 개, 지도 구역은 시드로 크기만 달리한 네모 블록 40개다. 실제 배송 조회 화면에 옮긴다면 진행률 자리에 스크롤 대신 배송 단계에서 계산한 값을 넣는 쪽이 맞아 보이고, 그 경우에도 남은 거리를 길이 비율로 세는 계산은 그대로 쓸 수 있다.
01굽은 길도 같은 빠르기로 그어지는 선
지나는 점 9개를 일부러 고르지 않은 간격으로 놓은 길을 진행률 6%~94% 구간에서 긋는데, 진행률을 곡선 변수 u 로 곧장 쓰지 않고 PATH_SAMPLES 200 토막으로 잰 누적 길이 표에서 그 길이에 해당하는 u 를 이분 탐색으로 찾는다. 진행률을 같은 걸음으로 400번 올려 보면 펜 끝은 걸음마다 3.047~3.267px 씩 나아가 1.07배 안에 들고, 같은 길이마다 찍은 눈금 24개가 그 고름을 눈으로 확인시켜 주니 서비스 소개 페이지의 흐름선처럼 선이 일정하게 자라야 하는 곳에 맞는다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="굽은 길을 따라 같은 빠르기로 선이 그어지는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 곡선 토막 수 — 누적 길이 표를 이 개수의 토막으로 만든다. 많을수록 진행률→길이 변환이 실제 곡선 길이에 가깝다
const PATH_SAMPLES = 200;
// 선 긋기 구간 — 진행률이 DRAW_START 에서 DRAW_END 로 가는 동안 길이 0 에서 전체 길이까지 긋는다(앞뒤는 여유)
const DRAW_START = 0.06;
const DRAW_END = 0.94;
const DEMO_SEED = 4741;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
// 지나는 점 — 간격을 일부러 고르지 않게 두었다. u 를 그대로 쓰면 긴 토막에서 빨라지고 짧은 토막에서 느려진다
const ROUTE = [[34, 232], [70, 70], [118, 182], [196, 54], [232, 200], [318, 40], [352, 214], [420, 70], [452, 196]];
const TABLE = buildLengthTable(makeCurve(ROUTE), PATH_SAMPLES);
// 같은 길이 눈금 — 그어진 선 위에 전체 길이를 이 수로 나눈 간격마다 점을 찍어 고른 빠르기를 눈으로 보인다
const MARK_COUNT = 24;
// 지금까지 그은 길이(px)
let drawLen = 0;
function drawFraction(p) { return clamp((p - DRAW_START) / (DRAW_END - DRAW_START), 0, 1); }
function onRestart() { drawLen = drawFraction(prog) * TABLE.total; }
// 진행률 → 그을 길이. 길이 → 곡선 위 점은 그릴 때 누적 길이 표의 이분 탐색(uAtLength)이 찾는다
function step(dp) { drawLen = drawFraction(prog) * TABLE.total; }
function draw() {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 아직 안 그은 길 — 점선 안내
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
if (drawLen > 0.5) {
tracePath(TABLE, 0, drawLen, 600);
ctx.lineWidth = 24;
ctx.strokeStyle = rgba(LILAC, 0.2);
ctx.stroke();
const g = ctx.createLinearGradient(0, 0, W, 0);
g.addColorStop(0, rgba(SKY, 1));
g.addColorStop(1, rgba(PINK, 1));
ctx.lineWidth = 10;
ctx.strokeStyle = g;
ctx.stroke();
const gap = TABLE.total / MARK_COUNT;
ctx.fillStyle = rgba(BG, 0.8);
for (let s = gap; s < drawLen; s += gap) {
const q = pointAtLength(TABLE, s);
ctx.beginPath();
ctx.arc(q[0], q[1], 2.2, 0, TAU);
ctx.fill();
}
}
// 펜 끝
const tip = pointAtLength(TABLE, drawLen);
ctx.fillStyle = rgba(CREAM, 0.22);
ctx.beginPath();
ctx.arc(tip[0], tip[1], 15, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(tip[0], tip[1], 6.5, 0, TAU);
ctx.fill();
}
function stateText() {
const f = drawFraction(prog);
return pctText(prog) + ' — ' + (f <= 0 ? '선 긋기 전' : (f >= 1 ? '선을 다 그었어요' : '선 긋는 중'));
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// ArcLengthEvenLineDraw - vanilla 판(01-spa-arc-length-even-line-draw) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ArcLengthEvenLineDraw.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ArcLengthEvenLineDraw() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 곡선 토막 수 — 누적 길이 표를 이 개수의 토막으로 만든다. 많을수록 진행률→길이 변환이 실제 곡선 길이에 가깝다
const PATH_SAMPLES = 200;
// 선 긋기 구간 — 진행률이 DRAW_START 에서 DRAW_END 로 가는 동안 길이 0 에서 전체 길이까지 긋는다(앞뒤는 여유)
const DRAW_START = 0.06;
const DRAW_END = 0.94;
const DEMO_SEED = 4741;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
// 지나는 점 — 간격을 일부러 고르지 않게 두었다. u 를 그대로 쓰면 긴 토막에서 빨라지고 짧은 토막에서 느려진다
const ROUTE: Pt[] = [[34, 232], [70, 70], [118, 182], [196, 54], [232, 200], [318, 40], [352, 214], [420, 70], [452, 196]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), PATH_SAMPLES);
// 같은 길이 눈금 — 그어진 선 위에 전체 길이를 이 수로 나눈 간격마다 점을 찍어 고른 빠르기를 눈으로 보인다
const MARK_COUNT = 24;
// 지금까지 그은 길이(px)
let drawLen = 0;
function drawFraction(p: number): number { return clamp((p - DRAW_START) / (DRAW_END - DRAW_START), 0, 1); }
function onRestart(): void { drawLen = drawFraction(prog) * TABLE.total; }
// 진행률 → 그을 길이. 길이 → 곡선 위 점은 그릴 때 누적 길이 표의 이분 탐색(uAtLength)이 찾는다
function step(dp: number): void { drawLen = drawFraction(prog) * TABLE.total; }
function draw(): void {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 아직 안 그은 길 — 점선 안내
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
if (drawLen > 0.5) {
tracePath(TABLE, 0, drawLen, 600);
ctx.lineWidth = 24;
ctx.strokeStyle = rgba(LILAC, 0.2);
ctx.stroke();
const g = ctx.createLinearGradient(0, 0, W, 0);
g.addColorStop(0, rgba(SKY, 1));
g.addColorStop(1, rgba(PINK, 1));
ctx.lineWidth = 10;
ctx.strokeStyle = g;
ctx.stroke();
const gap = TABLE.total / MARK_COUNT;
ctx.fillStyle = rgba(BG, 0.8);
for (let s = gap; s < drawLen; s += gap) {
const q = pointAtLength(TABLE, s);
ctx.beginPath();
ctx.arc(q[0], q[1], 2.2, 0, TAU);
ctx.fill();
}
}
// 펜 끝
const tip = pointAtLength(TABLE, drawLen);
ctx.fillStyle = rgba(CREAM, 0.22);
ctx.beginPath();
ctx.arc(tip[0], tip[1], 15, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(tip[0], tip[1], 6.5, 0, TAU);
ctx.fill();
}
function stateText(): string {
const f = drawFraction(prog);
return pctText(prog) + ' — ' + (f <= 0 ? '선 긋기 전' : (f >= 1 ? '선을 다 그었어요' : '선 긋는 중'));
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="굽은 길을 따라 같은 빠르기로 선이 그어지는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}01 · 굽은 길도 같은 빠르기로 그어지는 선 — 측정 누적면적 18.596% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
02길의 접선 방향으로 고개를 돌리며 나는 종이비행기
비행기는 지금 길이에서 18px 앞선 점과의 차로 접선 각을 구하고, 머리와의 각도 차를 -180°~180° 로 접은 뒤 TURN_SMOOTH 0.14 만큼만 틱마다 돈다. 거꾸로 굴리면 마지막으로 움직인 방향 dir 이 -1 로 바뀌어 뒤쪽 점을 보므로 되돌아가기 시작한 순간 목표가 177.6° 뒤집히고, 비행기는 그 반 바퀴를 640ms 에 걸쳐 돌아 5° 안으로 들어온다. 분홍 점선이 목표 방향을 먼저 가리키고 비행기가 그 뒤를 쫓는 모습이라 문의하기 섹션으로 눈길을 끄는 장식에 어울린다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="종이비행기가 길의 접선 방향으로 머리를 돌리며 나는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
function makeIcon(back, front, under) {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon, x, y, angle, size, ox, oy, back, front, sy, alpha) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 앞을 내다보는 거리(px) — 지금 점과 이만큼 앞(거꾸로 굴리면 뒤) 점을 이은 방향이 접선 각
const FOLLOW_LOOKAHEAD = 18;
// 머리 돌리기 감쇠 — 틱마다 남은 각도 차의 이 비율만큼만 돈다(가장 짧은 방향으로)
const TURN_SMOOTH = 0.14;
// 비행기 크기(px) — 아이콘 한 변
const PLANE_SIZE = 44;
const DEMO_SEED = 4742;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
// 꺾임이 큰 지그재그 길 — 굽이마다 접선 각이 크게 바뀐다
const ROUTE = [[34, 236], [96, 116], [170, 214], [232, 72], [300, 196], [338, 96], [392, 222], [452, 64]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
const PLANE = makeIcon('M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96', 'm231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z', '');
// heading 은 비행기 머리 각, target 은 접선 각, turn 은 이번 틱에 돈 각(라디안), dir 은 마지막으로 움직인 방향(+1 앞 · -1 뒤)
let heading = 0;
let target = 0;
let turn = 0;
let dir = 1;
function travel() { return prog * TABLE.total; }
// 접선 각 — 앞선 점과 지금 점의 차. 길 끝이라 두 점이 겹치면 이전 각을 그대로 둔다
function aimAt(s) {
const a = pointAtLength(TABLE, s);
const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.hypot(dx, dy) < 0.5) {
return target;
}
return Math.atan2(dy, dx);
}
function onRestart() {
dir = 1;
target = aimAt(travel());
heading = target;
turn = 0;
}
function step(dp) {
if (dp > 1e-6) {
dir = 1;
}
else if (dp < -1e-6) {
dir = -1;
}
target = aimAt(travel());
// 가장 짧은 방향 — 각도 차를 -180°~180° 로 접은 뒤 그 비율만큼만 돈다
turn = wrapAngle(target - heading) * TURN_SMOOTH;
heading = wrapAngle(heading + turn);
}
function draw() {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
const s = travel();
// 지나온 길 — 비행운
if (s > 0.5) {
ctx.setLineDash([10, 9]);
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(SKY, 0.6);
tracePath(TABLE, 0, s, 600);
ctx.stroke();
}
const p = pointAtLength(TABLE, s);
// 목표 방향 — 접선이 가리키는 쪽. 비행기 머리는 이 선을 감쇠로 뒤쫓는다
ctx.setLineDash([4, 4]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
ctx.lineTo(p[0] + Math.cos(target) * 46, p[1] + Math.sin(target) * 46);
ctx.stroke();
ctx.setLineDash([]);
// 기울기 — 이번 틱에 돈 각만큼 세로로 눌러 날개를 기울인 것처럼
const bank = 1 - clamp(Math.abs(turn) * 7, 0, 0.5);
drawIcon(PLANE, p[0] + 9, p[1] + 15, heading, PLANE_SIZE, 136, 128, [0, 0, 0], [0, 0, 0], bank, 0.28);
drawIcon(PLANE, p[0], p[1], heading, PLANE_SIZE, 136, 128, SKY, CREAM, bank, 1);
}
function stateText() {
return pctText(prog) + ' — ' + (dir < 0 ? '거꾸로 나는 중' : (Math.abs(turn) > 0.01 ? '고개를 트는 중' : '곧게 나는 중'));
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// TangentTurnPaperPlane - vanilla 판(02-spa-tangent-turn-paper-plane) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TangentTurnPaperPlane.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TangentTurnPaperPlane() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 아이콘 — Phosphor Duotone(MIT) SVG 의 두 경로(옅은 면 back · 윤곽 front)를 그대로 Path2D 로 옮겨 캔버스에 찍는다. 좌표는 256 칸.
// under 는 윤곽 안을 메우는 바탕(그 아이콘의 바깥 꼭짓점을 이은 것) — 옅은 면이 일부만 덮는 아이콘에만 쓴다
type Icon = { back: Path2D; front: Path2D; under: Path2D | null };
function makeIcon(back: string, front: string, under: string): Icon {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon: Icon, x: number, y: number, angle: number, size: number, ox: number, oy: number, back: RGB, front: RGB, sy: number, alpha: number): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 앞을 내다보는 거리(px) — 지금 점과 이만큼 앞(거꾸로 굴리면 뒤) 점을 이은 방향이 접선 각
const FOLLOW_LOOKAHEAD = 18;
// 머리 돌리기 감쇠 — 틱마다 남은 각도 차의 이 비율만큼만 돈다(가장 짧은 방향으로)
const TURN_SMOOTH = 0.14;
// 비행기 크기(px) — 아이콘 한 변
const PLANE_SIZE = 44;
const DEMO_SEED = 4742;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
// 꺾임이 큰 지그재그 길 — 굽이마다 접선 각이 크게 바뀐다
const ROUTE: Pt[] = [[34, 236], [96, 116], [170, 214], [232, 72], [300, 196], [338, 96], [392, 222], [452, 64]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
const PLANE: Icon = makeIcon(
'M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96',
'm231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z',
'');
// heading 은 비행기 머리 각, target 은 접선 각, turn 은 이번 틱에 돈 각(라디안), dir 은 마지막으로 움직인 방향(+1 앞 · -1 뒤)
let heading = 0;
let target = 0;
let turn = 0;
let dir = 1;
function travel(): number { return prog * TABLE.total; }
// 접선 각 — 앞선 점과 지금 점의 차. 길 끝이라 두 점이 겹치면 이전 각을 그대로 둔다
function aimAt(s: number): number {
const a = pointAtLength(TABLE, s);
const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.hypot(dx, dy) < 0.5) { return target; }
return Math.atan2(dy, dx);
}
function onRestart(): void {
dir = 1;
target = aimAt(travel());
heading = target;
turn = 0;
}
function step(dp: number): void {
if (dp > 1e-6) { dir = 1; } else if (dp < -1e-6) { dir = -1; }
target = aimAt(travel());
// 가장 짧은 방향 — 각도 차를 -180°~180° 로 접은 뒤 그 비율만큼만 돈다
turn = wrapAngle(target - heading) * TURN_SMOOTH;
heading = wrapAngle(heading + turn);
}
function draw(): void {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
const s = travel();
// 지나온 길 — 비행운
if (s > 0.5) {
ctx.setLineDash([10, 9]);
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(SKY, 0.6);
tracePath(TABLE, 0, s, 600);
ctx.stroke();
}
const p = pointAtLength(TABLE, s);
// 목표 방향 — 접선이 가리키는 쪽. 비행기 머리는 이 선을 감쇠로 뒤쫓는다
ctx.setLineDash([4, 4]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
ctx.lineTo(p[0] + Math.cos(target) * 46, p[1] + Math.sin(target) * 46);
ctx.stroke();
ctx.setLineDash([]);
// 기울기 — 이번 틱에 돈 각만큼 세로로 눌러 날개를 기울인 것처럼
const bank = 1 - clamp(Math.abs(turn) * 7, 0, 0.5);
drawIcon(PLANE, p[0] + 9, p[1] + 15, heading, PLANE_SIZE, 136, 128, [0, 0, 0], [0, 0, 0], bank, 0.28);
drawIcon(PLANE, p[0], p[1], heading, PLANE_SIZE, 136, 128, SKY, CREAM, bank, 1);
}
function stateText(): string {
return pctText(prog) + ' — ' + (dir < 0 ? '거꾸로 나는 중' : (Math.abs(turn) > 0.01 ? '고개를 트는 중' : '곧게 나는 중'));
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="종이비행기가 길의 접선 방향으로 머리를 돌리며 나는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}02 · 길의 접선 방향으로 고개를 돌리며 나는 종이비행기 — 측정 누적면적 11.272% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function aimAt(s) {
const a = pointAtLength(TABLE, s);
const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.hypot(dx, dy) < 0.5) {
return target;
}
return Math.atan2(dy, dx);
}
function step(dp) {
if (dp > 1e-6) {
dir = 1;
}
else if (dp < -1e-6) {
dir = -1;
}
target = aimAt(travel());
// 가장 짧은 방향 — 각도 차를 -180°~180° 로 접은 뒤 그 비율만큼만 돈다
turn = wrapAngle(target - heading) * TURN_SMOOTH;
heading = wrapAngle(heading + turn);
}
03지나는 순간 스프링으로 솟는 길 위 이정표
이정표 5개는 곡선 변수 u 를 0.1·0.3·0.5·0.7·0.9 로 고르게 나눈 자리에 세우지만 길이로 재면 길 543.2px 의 8.0·27.1·48.0·69.3·90.3% 지점이라서, lengthAtU 로 자리마다 길이를 미리 재 두고 진행 길이가 그 값을 넘는 순간 깃발의 목표를 1 로 바꾼다. 강성 0.12·감쇠 0.8 의 스프링이라 깃발은 곧게 선 자리를 29.2° 넘어 젖혀졌다가 544ms 에 자리를 잡고, 되돌려 굴리면 같은 544ms 동안 반대쪽으로 출렁이며 눕는다. 연도를 지날 때마다 표식이 일어서는 회사 연혁 타임라인으로 옮기기 좋다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="길 위 이정표가 지나는 순간 스프링으로 솟고, 되돌리면 다시 눕는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
function makeIcon(back, front, under) {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon, x, y, angle, size, ox, oy, back, front, sy, alpha) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 이정표 수 — u 를 고르게 나눈 자리에 세우고, 그 자리의 누적 길이를 미리 재 둔다
const MILESTONE_COUNT = 5;
// 스프링 세기 — 틱마다 (목표 − 지금) 에 곱해 속도에 더한다
const PIN_STIFFNESS = 0.12;
// 감쇠 — 틱마다 속도에 곱한다(1 이면 흔들림이 줄지 않는다)
const PIN_DAMPING = 0.8;
// 깃발 크기(px) — 아이콘 한 변
const FLAG_SIZE = 58;
const DEMO_SEED = 4743;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[24, 214], [104, 168], [190, 226], [280, 150], [368, 214], [456, 150]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
const FLAG = makeIcon('M224 56v120c-64 55.43-112-55.43-176 0V56c64-55.43 112 55.43 176 0', 'M42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c16.4 8.11 34.06 16.85 53 16.85c13.93 0 28.54-4.75 43.82-18a8 8 0 0 0 2.76-6V56a8 8 0 0 0-13.27-6c-28 24.23-51.72 12.49-79.21-1.12C111.07 34.76 78.78 18.79 42.76 50M216 172.25c-26.79 21.16-49.87 9.74-76.45-3.41c-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16 49.87-9.75 76.45 3.4c25 12.35 52.82 26.13 83.55 8.4Z', '');
const PIN_COLORS = [SKY, PINK, LEMON, LILAC, PEACH];
const PINS = [];
for (let i = 0; i < MILESTONE_COUNT; i += 1) {
PINS.push({ s: lengthAtU(TABLE, (i + 0.5) / MILESTONE_COUNT), x: 0, v: 0, up: false });
}
function travel() { return prog * TABLE.total; }
function onRestart() {
const s = travel();
for (let i = 0; i < PINS.length; i += 1) {
PINS[i].up = s >= PINS[i].s;
PINS[i].x = PINS[i].up ? 1 : 0;
PINS[i].v = 0;
}
}
// 진행 길이가 이정표 길이를 넘으면 목표 1(서기), 되돌아 못 미치면 목표 0(눕기) — 스프링이 넘쳤다 자리 잡는다
function step(dp) {
const s = travel();
for (let i = 0; i < PINS.length; i += 1) {
const pin = PINS[i];
pin.up = s >= pin.s;
const goal = pin.up ? 1 : 0;
pin.v += (goal - pin.x) * PIN_STIFFNESS;
pin.v *= PIN_DAMPING;
pin.x += pin.v;
}
}
function passedCount() {
let n = 0;
for (let i = 0; i < PINS.length; i += 1) {
if (PINS[i].up) {
n += 1;
}
}
return n;
}
function draw() {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
tracePath(TABLE, 0, TABLE.total, 600);
ctx.lineWidth = 20;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.stroke();
const s = travel();
if (s > 0.5) {
tracePath(TABLE, 0, s, 600);
ctx.lineWidth = 8;
ctx.strokeStyle = rgba(MINT, 1);
ctx.stroke();
}
for (let i = 0; i < PINS.length; i += 1) {
const pin = PINS[i];
const b = pointAtLength(TABLE, pin.s);
// 누운 각 −90°(장대가 왼쪽으로 누움) → 선 각 0°. 스프링이 1 을 넘으면 오른쪽으로 잠깐 더 젖혀진다
const lean = (1 - pin.x) * (-Math.PI / 2);
ctx.fillStyle = rgba([0, 0, 0], 0.3);
ctx.beginPath();
ctx.ellipse(b[0] + 4, b[1] + 2, 6 + 14 * clamp(pin.x, 0, 1.2), 3.5, 0, 0, TAU);
ctx.fill();
drawIcon(FLAG, b[0], b[1], lean, FLAG_SIZE, 48, 224, PIN_COLORS[i % PIN_COLORS.length], CREAM, 1, 1);
// 번호 — 깃발 천 가운데
const k = FLAG_SIZE / 256;
ctx.save();
ctx.translate(b[0], b[1]);
ctx.rotate(lean);
ctx.fillStyle = rgba(BG, 1);
ctx.font = '800 13px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), (136 - 48) * k, (108 - 224) * k);
ctx.restore();
ctx.fillStyle = rgba(pin.up ? MINT : BG, 1);
ctx.beginPath();
ctx.arc(b[0], b[1], 4.5, 0, TAU);
ctx.fill();
}
// 길을 가는 점
const p = pointAtLength(TABLE, s);
ctx.fillStyle = rgba(CREAM, 0.25);
ctx.beginPath();
ctx.arc(p[0], p[1], 15, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(p[0], p[1], 8, 0, TAU);
ctx.fill();
}
function stateText() {
return pctText(prog) + ' — ' + '이정표 ' + passedCount() + '/' + MILESTONE_COUNT + ' 지남';
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// MilestonePinSpringPop - vanilla 판(03-spa-milestone-pin-spring-pop) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MilestonePinSpringPop.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MilestonePinSpringPop() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 아이콘 — Phosphor Duotone(MIT) SVG 의 두 경로(옅은 면 back · 윤곽 front)를 그대로 Path2D 로 옮겨 캔버스에 찍는다. 좌표는 256 칸.
// under 는 윤곽 안을 메우는 바탕(그 아이콘의 바깥 꼭짓점을 이은 것) — 옅은 면이 일부만 덮는 아이콘에만 쓴다
type Icon = { back: Path2D; front: Path2D; under: Path2D | null };
function makeIcon(back: string, front: string, under: string): Icon {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon: Icon, x: number, y: number, angle: number, size: number, ox: number, oy: number, back: RGB, front: RGB, sy: number, alpha: number): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 이정표 수 — u 를 고르게 나눈 자리에 세우고, 그 자리의 누적 길이를 미리 재 둔다
const MILESTONE_COUNT = 5;
// 스프링 세기 — 틱마다 (목표 − 지금) 에 곱해 속도에 더한다
const PIN_STIFFNESS = 0.12;
// 감쇠 — 틱마다 속도에 곱한다(1 이면 흔들림이 줄지 않는다)
const PIN_DAMPING = 0.8;
// 깃발 크기(px) — 아이콘 한 변
const FLAG_SIZE = 58;
const DEMO_SEED = 4743;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[24, 214], [104, 168], [190, 226], [280, 150], [368, 214], [456, 150]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
const FLAG: Icon = makeIcon(
'M224 56v120c-64 55.43-112-55.43-176 0V56c64-55.43 112 55.43 176 0',
'M42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c16.4 8.11 34.06 16.85 53 16.85c13.93 0 28.54-4.75 43.82-18a8 8 0 0 0 2.76-6V56a8 8 0 0 0-13.27-6c-28 24.23-51.72 12.49-79.21-1.12C111.07 34.76 78.78 18.79 42.76 50M216 172.25c-26.79 21.16-49.87 9.74-76.45-3.41c-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16 49.87-9.75 76.45 3.4c25 12.35 52.82 26.13 83.55 8.4Z',
'');
const PIN_COLORS: RGB[] = [SKY, PINK, LEMON, LILAC, PEACH];
// 핀 — s 는 미리 잰 누적 길이, x 는 서 있는 정도(0 누움 · 1 섬), v 는 그 속도
type Pin = { s: number; x: number; v: number; up: boolean };
const PINS: Pin[] = [];
for (let i = 0; i < MILESTONE_COUNT; i += 1) {
PINS.push({ s: lengthAtU(TABLE, (i + 0.5) / MILESTONE_COUNT), x: 0, v: 0, up: false });
}
function travel(): number { return prog * TABLE.total; }
function onRestart(): void {
const s = travel();
for (let i = 0; i < PINS.length; i += 1) {
PINS[i].up = s >= PINS[i].s;
PINS[i].x = PINS[i].up ? 1 : 0;
PINS[i].v = 0;
}
}
// 진행 길이가 이정표 길이를 넘으면 목표 1(서기), 되돌아 못 미치면 목표 0(눕기) — 스프링이 넘쳤다 자리 잡는다
function step(dp: number): void {
const s = travel();
for (let i = 0; i < PINS.length; i += 1) {
const pin = PINS[i];
pin.up = s >= pin.s;
const goal = pin.up ? 1 : 0;
pin.v += (goal - pin.x) * PIN_STIFFNESS;
pin.v *= PIN_DAMPING;
pin.x += pin.v;
}
}
function passedCount(): number {
let n = 0;
for (let i = 0; i < PINS.length; i += 1) { if (PINS[i].up) { n += 1; } }
return n;
}
function draw(): void {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
tracePath(TABLE, 0, TABLE.total, 600);
ctx.lineWidth = 20;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.stroke();
const s = travel();
if (s > 0.5) {
tracePath(TABLE, 0, s, 600);
ctx.lineWidth = 8;
ctx.strokeStyle = rgba(MINT, 1);
ctx.stroke();
}
for (let i = 0; i < PINS.length; i += 1) {
const pin = PINS[i];
const b = pointAtLength(TABLE, pin.s);
// 누운 각 −90°(장대가 왼쪽으로 누움) → 선 각 0°. 스프링이 1 을 넘으면 오른쪽으로 잠깐 더 젖혀진다
const lean = (1 - pin.x) * (-Math.PI / 2);
ctx.fillStyle = rgba([0, 0, 0], 0.3);
ctx.beginPath();
ctx.ellipse(b[0] + 4, b[1] + 2, 6 + 14 * clamp(pin.x, 0, 1.2), 3.5, 0, 0, TAU);
ctx.fill();
drawIcon(FLAG, b[0], b[1], lean, FLAG_SIZE, 48, 224, PIN_COLORS[i % PIN_COLORS.length], CREAM, 1, 1);
// 번호 — 깃발 천 가운데
const k = FLAG_SIZE / 256;
ctx.save();
ctx.translate(b[0], b[1]);
ctx.rotate(lean);
ctx.fillStyle = rgba(BG, 1);
ctx.font = '800 13px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), (136 - 48) * k, (108 - 224) * k);
ctx.restore();
ctx.fillStyle = rgba(pin.up ? MINT : BG, 1);
ctx.beginPath();
ctx.arc(b[0], b[1], 4.5, 0, TAU);
ctx.fill();
}
// 길을 가는 점
const p = pointAtLength(TABLE, s);
ctx.fillStyle = rgba(CREAM, 0.25);
ctx.beginPath();
ctx.arc(p[0], p[1], 15, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(p[0], p[1], 8, 0, TAU);
ctx.fill();
}
function stateText(): string {
return pctText(prog) + ' — ' + '이정표 ' + passedCount() + '/' + MILESTONE_COUNT + ' 지남';
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="길 위 이정표가 지나는 순간 스프링으로 솟고, 되돌리면 다시 눕는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}03 · 지나는 순간 스프링으로 솟는 길 위 이정표 — 측정 누적면적 15.065% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step(dp) {
const s = travel();
for (let i = 0; i < PINS.length; i += 1) {
const pin = PINS[i];
pin.up = s >= pin.s;
const goal = pin.up ? 1 : 0;
pin.v += (goal - pin.x) * PIN_STIFFNESS;
pin.v *= PIN_DAMPING;
pin.x += pin.v;
}
}
04빨리 굴릴수록 굵어지고 출렁이는 선
틱마다 진행률 차의 절댓값을 VELOCITY_SMOOTH 0.08 저역 필터로 고른 빠르기가 진행률/틱 0.008 에서 상한에 닿고, 그 값에 비례해 표시점 둘레의 선이 굵기 3px 에서 16px 까지 부풀며 법선 방향 사인 물결이 최대 14px 비킨다. 표시점에서 멀어질수록 물결과 굵기는 150px 폭의 종 모양으로 잦아들고, 손을 떼면 필터 값은 464ms 만에 8% 아래로, 물결 폭은 480ms 만에 1px 아래로 가라앉아 랜딩 페이지 구분선이 굴린 기세를 잠깐 머금었다 놓는 모습이 된다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="굴리는 빠르기만큼 선이 굵어지고 출렁이다가, 멈추면 잦아드는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 빠르기 저역 필터 — 틱마다 새 빠르기와의 차에 이 비율만큼만 다가간다(작을수록 느긋하게 오르고 잦아든다)
const VELOCITY_SMOOTH = 0.08;
// 물결 폭 상한(px) — 가장 빠를 때 법선 방향으로 이만큼 비킨다
const WOBBLE_AMP_MAX = 14;
// 선 굵기 상한·하한(px)
const STROKE_WIDTH_MAX = 16;
const STROKE_WIDTH_MIN = 3;
// 이 빠르기(진행률/틱)에서 물결·굵기가 상한에 닿는다
const VELOCITY_FULL = 0.008;
// 물결 — 길이 1px 당 위상(라디안), 진행률 1 당 위상 이동(굴린 만큼 물결이 길을 따라 흐른다)
const WAVE_K = 0.06;
const WAVE_DRIFT = 40;
// 물결이 퍼지는 범위(px) — 표시점에서 멀어질수록 폭·굵기가 종 모양(가우스)으로 줄어든다
const WAVE_REACH = 150;
// 선을 끊어 그리는 간격(px)
const WAVE_STEP = 3;
const DEMO_SEED = 4744;
const DEMO_LEGS = 5;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[-10, 160], [90, 104], [200, 178], [300, 112], [400, 176], [490, 128]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
// 고른 빠르기(진행률/틱)
let vel = 0;
function level() { return clamp(vel / VELOCITY_FULL, 0, 1); }
function onRestart() { vel = 0; }
// 빠르기 = 틱마다 진행률 차의 크기. 저역 필터로 고른다
function step(dp) { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
// 길이 s 자리의 물결 — 법선 방향 사인 비킴 × 종 모양 감쇠
function waveAt(s, s0, amp) {
const base = pointAtLength(TABLE, s);
const n = normalAt(TABLE, s);
const d = (s - s0) / WAVE_REACH;
const env = Math.exp(-d * d);
const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
}
function draw() {
clearStage();
const lv = level();
const s0 = prog * TABLE.total;
const amp = WOBBLE_AMP_MAX * lv;
const widthPeak = lerp(STROKE_WIDTH_MIN, STROKE_WIDTH_MAX, lv);
// 쉬는 모양 — 물결 없는 원래 길
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
const pts = [];
const ws = [];
for (let s = 0; s <= TABLE.total; s += WAVE_STEP) {
const w = waveAt(s, s0, amp);
pts.push(w[0]);
ws.push(lerp(STROKE_WIDTH_MIN, widthPeak, w[1]));
}
const col = mixRGB(SKY, PINK, lv);
ctx.lineCap = 'round';
const passes = [[2.6, 0.16], [1, 1]];
for (let q = 0; q < passes.length; q += 1) {
ctx.strokeStyle = rgba(col, passes[q][1]);
for (let i = 0; i < pts.length - 1; i += 1) {
ctx.lineWidth = ws[i] * passes[q][0];
ctx.beginPath();
ctx.moveTo(pts[i][0], pts[i][1]);
ctx.lineTo(pts[i + 1][0], pts[i + 1][1]);
ctx.stroke();
}
}
const m = waveAt(s0, s0, amp)[0];
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(m[0], m[1], 7, 0, TAU);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
function stateText() {
const lv = level();
return pctText(prog) + ' — ' + (lv > 0.5 ? '빠르게 굴리는 중' : (lv > 0.08 ? '출렁임이 잦아드는 중' : '잔잔한 선'));
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// VelocityWobbleStroke - vanilla 판(04-spa-velocity-wobble-stroke) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./VelocityWobbleStroke.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function VelocityWobbleStroke() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 빠르기 저역 필터 — 틱마다 새 빠르기와의 차에 이 비율만큼만 다가간다(작을수록 느긋하게 오르고 잦아든다)
const VELOCITY_SMOOTH = 0.08;
// 물결 폭 상한(px) — 가장 빠를 때 법선 방향으로 이만큼 비킨다
const WOBBLE_AMP_MAX = 14;
// 선 굵기 상한·하한(px)
const STROKE_WIDTH_MAX = 16;
const STROKE_WIDTH_MIN = 3;
// 이 빠르기(진행률/틱)에서 물결·굵기가 상한에 닿는다
const VELOCITY_FULL = 0.008;
// 물결 — 길이 1px 당 위상(라디안), 진행률 1 당 위상 이동(굴린 만큼 물결이 길을 따라 흐른다)
const WAVE_K = 0.06;
const WAVE_DRIFT = 40;
// 물결이 퍼지는 범위(px) — 표시점에서 멀어질수록 폭·굵기가 종 모양(가우스)으로 줄어든다
const WAVE_REACH = 150;
// 선을 끊어 그리는 간격(px)
const WAVE_STEP = 3;
const DEMO_SEED = 4744;
const DEMO_LEGS = 5;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[-10, 160], [90, 104], [200, 178], [300, 112], [400, 176], [490, 128]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
// 고른 빠르기(진행률/틱)
let vel = 0;
function level(): number { return clamp(vel / VELOCITY_FULL, 0, 1); }
function onRestart(): void { vel = 0; }
// 빠르기 = 틱마다 진행률 차의 크기. 저역 필터로 고른다
function step(dp: number): void { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
// 길이 s 자리의 물결 — 법선 방향 사인 비킴 × 종 모양 감쇠
function waveAt(s: number, s0: number, amp: number): [Pt, number] {
const base = pointAtLength(TABLE, s);
const n = normalAt(TABLE, s);
const d = (s - s0) / WAVE_REACH;
const env = Math.exp(-d * d);
const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
}
function draw(): void {
clearStage();
const lv = level();
const s0 = prog * TABLE.total;
const amp = WOBBLE_AMP_MAX * lv;
const widthPeak = lerp(STROKE_WIDTH_MIN, STROKE_WIDTH_MAX, lv);
// 쉬는 모양 — 물결 없는 원래 길
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
const pts: Pt[] = [];
const ws: number[] = [];
for (let s = 0; s <= TABLE.total; s += WAVE_STEP) {
const w = waveAt(s, s0, amp);
pts.push(w[0]);
ws.push(lerp(STROKE_WIDTH_MIN, widthPeak, w[1]));
}
const col = mixRGB(SKY, PINK, lv);
ctx.lineCap = 'round';
const passes: Pt[] = [[2.6, 0.16], [1, 1]];
for (let q = 0; q < passes.length; q += 1) {
ctx.strokeStyle = rgba(col, passes[q][1]);
for (let i = 0; i < pts.length - 1; i += 1) {
ctx.lineWidth = ws[i] * passes[q][0];
ctx.beginPath();
ctx.moveTo(pts[i][0], pts[i][1]);
ctx.lineTo(pts[i + 1][0], pts[i + 1][1]);
ctx.stroke();
}
}
const m = waveAt(s0, s0, amp)[0];
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(m[0], m[1], 7, 0, TAU);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
function stateText(): string {
const lv = level();
return pctText(prog) + ' — ' + (lv > 0.5 ? '빠르게 굴리는 중' : (lv > 0.08 ? '출렁임이 잦아드는 중' : '잔잔한 선'));
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="굴리는 빠르기만큼 선이 굵어지고 출렁이다가, 멈추면 잦아드는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}04 · 빨리 굴릴수록 굵어지고 출렁이는 선 — 측정 누적면적 17.865% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step(dp) { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
// 길이 s 자리의 물결 — 법선 방향 사인 비킴 × 종 모양 감쇠
function waveAt(s, s0, amp) {
const base = pointAtLength(TABLE, s);
const n = normalAt(TABLE, s);
const d = (s - s0) / WAVE_REACH;
const env = Math.exp(-d * d);
const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
}
05나선 계단을 돌아 내려가는 공과 원근
진행률 하나가 나선의 각도(2.5바퀴)와 높이(300)를 함께 정비례로 정하므로 이 장면은 길이 표 없이도 같은 빠르기로 내려가고, 계단 점은 카메라 높이를 빼고 0.5 rad 기울인 뒤 360 ÷ (360 + 깊이) 배율로 화면에 옮긴다. 그 배율이 가장 먼 판 끝 0.751 에서 가장 가까운 쪽 1.496 까지 벌어져 공 지름은 21.5px 에서 34.3px 사이를 오가고, 판·기둥 토막·공 그림자·공을 깊이 큰 것부터 다시 정렬해 그리며 가장 먼 쪽은 바탕색으로 55% 섞어 흐린다. 앱 기능을 한 단계씩 내려가며 소개하는 장면에 쓴다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="공이 나선 계단을 돌아 내려가고, 먼 쪽 계단은 작고 흐리게 보이는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 나선 바퀴 수 — 진행률 0 → 1 동안 공이 이만큼 돈다
const HELIX_TURNS = 2.5;
// 나선 반지름(3D 단위, px) — 계단 판 바깥 끝까지
const HELIX_RADIUS = 136;
// 카메라 거리 — 화면 배율 = CAMERA_DISTANCE / (CAMERA_DISTANCE + 깊이). 작을수록 앞뒤 크기 차가 커진다
const CAMERA_DISTANCE = 360;
// 한 바퀴에 내려가는 높이
const TURN_DROP = 120;
// 내려다보는 각(라디안)
const CAMERA_TILT = 0.5;
// 계단 판 수(나선 전체)
const STEP_COUNT = 34;
// 판 안쪽 반지름 · 공이 구르는 반지름 · 공 반지름
const INNER_RADIUS = 34;
const BALL_PATH_RADIUS = 92;
const BALL_RADIUS = 13;
// 카메라가 공 높이를 따라가는 비율(틱마다)
const CAMERA_FOLLOW = 0.1;
const DEMO_SEED = 4745;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const CX = 240;
const CY = 150;
const STEP_COLORS = [SKY, PINK, MINT, LILAC, PEACH];
// 카메라 높이 — 공을 따라 내려간다
let camY = 0;
function ballAngle(p) { return p * HELIX_TURNS * TAU; }
function ballDrop(p) { return p * HELIX_TURNS * TURN_DROP; }
// 원근 투영 — 카메라 높이만큼 빼고 X 축으로 CAMERA_TILT 돌린 뒤, (카메라 거리 + 깊이) 로 나눠 화면에 놓는다
function project(x, y, z) {
const yy = y - camY;
const c = Math.cos(CAMERA_TILT);
const sn = Math.sin(CAMERA_TILT);
const y2 = yy * c - z * sn;
const z2 = yy * sn + z * c;
const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
}
function onRestart() { camY = ballDrop(prog); }
function step(dp) { camY += (ballDrop(prog) - camY) * CAMERA_FOLLOW; }
// 먼 쪽 흐림 — 판 가운데 각의 sin 이 1(가장 먼 쪽)이면 무대 바탕색 쪽으로 55% 섞는다
function fog(c, a) { return mixRGB(c, BG, 0.55 * (Math.sin(a) + 1) / 2); }
function polygon(ps) {
ctx.beginPath();
for (let i = 0; i < ps.length; i += 1) {
if (i === 0) {
ctx.moveTo(ps[i].x, ps[i].y);
}
else {
ctx.lineTo(ps[i].x, ps[i].y);
}
}
ctx.closePath();
}
function draw() {
clearStage();
const items = [];
const span = HELIX_TURNS * TAU / STEP_COUNT;
for (let i = -3; i < STEP_COUNT + 3; i += 1) {
const a = (i + 0.5) * span;
const y = a / TAU * TURN_DROP;
if (Math.abs(y - camY) > 300) {
continue;
}
const h = span * 0.46;
const ps = [];
const outer = [a - h, a, a + h];
for (let j = 0; j < outer.length; j += 1) {
ps.push(project(Math.cos(outer[j]) * HELIX_RADIUS, y, Math.sin(outer[j]) * HELIX_RADIUS));
}
ps.push(project(Math.cos(a + h) * INNER_RADIUS, y, Math.sin(a + h) * INNER_RADIUS));
ps.push(project(Math.cos(a - h) * INNER_RADIUS, y, Math.sin(a - h) * INNER_RADIUS));
let zs = 0;
for (let j = 0; j < ps.length; j += 1) {
zs += ps[j].z;
}
const col = fog(STEP_COLORS[((i % 5) + 5) % 5], a);
items.push({ z: zs / ps.length, paint: function () {
polygon(ps);
ctx.fillStyle = rgba(col, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(BG, 0.7);
ctx.stroke();
} });
}
// 가운데 기둥 — 토막으로 나눠 판 사이에 끼워 정렬한다
for (let y = camY - 300; y < camY + 300; y += 24) {
const top = project(0, y, 0);
const bot = project(0, y + 24, 0);
items.push({ z: (top.z + bot.z) / 2, paint: function () {
ctx.fillStyle = rgba(mixRGB(GUIDE, CREAM, 0.2), 1);
ctx.fillRect(top.x - 9 * top.k, top.y, 18 * top.k, bot.y - top.y + 1);
} });
}
// 공과 그 그림자
const ba = ballAngle(prog);
const bx = Math.cos(ba) * BALL_PATH_RADIUS;
const bz = Math.sin(ba) * BALL_PATH_RADIUS;
const by = ballDrop(prog);
const ball = project(bx, by - BALL_RADIUS, bz);
const shade = project(bx, by, bz);
items.push({ z: ball.z + 0.5, paint: function () {
ctx.fillStyle = rgba([0, 0, 0], 0.35);
ctx.beginPath();
ctx.ellipse(shade.x, shade.y, BALL_RADIUS * 1.1 * shade.k, BALL_RADIUS * 0.45 * shade.k, 0, 0, TAU);
ctx.fill();
} });
items.push({ z: ball.z, paint: function () {
const r = BALL_RADIUS * ball.k;
const g = ctx.createRadialGradient(ball.x - r * 0.35, ball.y - r * 0.4, r * 0.1, ball.x, ball.y, r);
g.addColorStop(0, rgba(CREAM, 1));
g.addColorStop(1, rgba(fog(PEACH, ba), 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ball.x, ball.y, r, 0, TAU);
ctx.fill();
} });
// 깊이 정렬 — 먼 것(깊이가 큰 것)부터 그려 앞의 것이 덮게 한다
items.sort(function (p, q) { return q.z - p.z; });
for (let i = 0; i < items.length; i += 1) {
items[i].paint();
}
}
function stateText() {
return pctText(prog) + ' — ' + '나선 ' + (prog * HELIX_TURNS).toFixed(1) + ' / ' + HELIX_TURNS + ' 바퀴';
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// HelixPerspectiveDepthSort - vanilla 판(05-spa-helix-perspective-depth-sort) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HelixPerspectiveDepthSort.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HelixPerspectiveDepthSort() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 나선 바퀴 수 — 진행률 0 → 1 동안 공이 이만큼 돈다
const HELIX_TURNS = 2.5;
// 나선 반지름(3D 단위, px) — 계단 판 바깥 끝까지
const HELIX_RADIUS = 136;
// 카메라 거리 — 화면 배율 = CAMERA_DISTANCE / (CAMERA_DISTANCE + 깊이). 작을수록 앞뒤 크기 차가 커진다
const CAMERA_DISTANCE = 360;
// 한 바퀴에 내려가는 높이
const TURN_DROP = 120;
// 내려다보는 각(라디안)
const CAMERA_TILT = 0.5;
// 계단 판 수(나선 전체)
const STEP_COUNT = 34;
// 판 안쪽 반지름 · 공이 구르는 반지름 · 공 반지름
const INNER_RADIUS = 34;
const BALL_PATH_RADIUS = 92;
const BALL_RADIUS = 13;
// 카메라가 공 높이를 따라가는 비율(틱마다)
const CAMERA_FOLLOW = 0.1;
const DEMO_SEED = 4745;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const CX = 240;
const CY = 150;
const STEP_COLORS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
// 카메라 높이 — 공을 따라 내려간다
let camY = 0;
type Proj = { x: number; y: number; z: number; k: number };
type Paint = { z: number; paint: () => void };
function ballAngle(p: number): number { return p * HELIX_TURNS * TAU; }
function ballDrop(p: number): number { return p * HELIX_TURNS * TURN_DROP; }
// 원근 투영 — 카메라 높이만큼 빼고 X 축으로 CAMERA_TILT 돌린 뒤, (카메라 거리 + 깊이) 로 나눠 화면에 놓는다
function project(x: number, y: number, z: number): Proj {
const yy = y - camY;
const c = Math.cos(CAMERA_TILT);
const sn = Math.sin(CAMERA_TILT);
const y2 = yy * c - z * sn;
const z2 = yy * sn + z * c;
const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
}
function onRestart(): void { camY = ballDrop(prog); }
function step(dp: number): void { camY += (ballDrop(prog) - camY) * CAMERA_FOLLOW; }
// 먼 쪽 흐림 — 판 가운데 각의 sin 이 1(가장 먼 쪽)이면 무대 바탕색 쪽으로 55% 섞는다
function fog(c: RGB, a: number): RGB { return mixRGB(c, BG, 0.55 * (Math.sin(a) + 1) / 2); }
function polygon(ps: Proj[]): void {
ctx.beginPath();
for (let i = 0; i < ps.length; i += 1) {
if (i === 0) { ctx.moveTo(ps[i].x, ps[i].y); } else { ctx.lineTo(ps[i].x, ps[i].y); }
}
ctx.closePath();
}
function draw(): void {
clearStage();
const items: Paint[] = [];
const span = HELIX_TURNS * TAU / STEP_COUNT;
for (let i = -3; i < STEP_COUNT + 3; i += 1) {
const a = (i + 0.5) * span;
const y = a / TAU * TURN_DROP;
if (Math.abs(y - camY) > 300) { continue; }
const h = span * 0.46;
const ps: Proj[] = [];
const outer = [a - h, a, a + h];
for (let j = 0; j < outer.length; j += 1) { ps.push(project(Math.cos(outer[j]) * HELIX_RADIUS, y, Math.sin(outer[j]) * HELIX_RADIUS)); }
ps.push(project(Math.cos(a + h) * INNER_RADIUS, y, Math.sin(a + h) * INNER_RADIUS));
ps.push(project(Math.cos(a - h) * INNER_RADIUS, y, Math.sin(a - h) * INNER_RADIUS));
let zs = 0;
for (let j = 0; j < ps.length; j += 1) { zs += ps[j].z; }
const col = fog(STEP_COLORS[((i % 5) + 5) % 5], a);
items.push({ z: zs / ps.length, paint: function (): void {
polygon(ps);
ctx.fillStyle = rgba(col, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(BG, 0.7);
ctx.stroke();
} });
}
// 가운데 기둥 — 토막으로 나눠 판 사이에 끼워 정렬한다
for (let y = camY - 300; y < camY + 300; y += 24) {
const top = project(0, y, 0);
const bot = project(0, y + 24, 0);
items.push({ z: (top.z + bot.z) / 2, paint: function (): void {
ctx.fillStyle = rgba(mixRGB(GUIDE, CREAM, 0.2), 1);
ctx.fillRect(top.x - 9 * top.k, top.y, 18 * top.k, bot.y - top.y + 1);
} });
}
// 공과 그 그림자
const ba = ballAngle(prog);
const bx = Math.cos(ba) * BALL_PATH_RADIUS;
const bz = Math.sin(ba) * BALL_PATH_RADIUS;
const by = ballDrop(prog);
const ball = project(bx, by - BALL_RADIUS, bz);
const shade = project(bx, by, bz);
items.push({ z: ball.z + 0.5, paint: function (): void {
ctx.fillStyle = rgba([0, 0, 0], 0.35);
ctx.beginPath();
ctx.ellipse(shade.x, shade.y, BALL_RADIUS * 1.1 * shade.k, BALL_RADIUS * 0.45 * shade.k, 0, 0, TAU);
ctx.fill();
} });
items.push({ z: ball.z, paint: function (): void {
const r = BALL_RADIUS * ball.k;
const g = ctx.createRadialGradient(ball.x - r * 0.35, ball.y - r * 0.4, r * 0.1, ball.x, ball.y, r);
g.addColorStop(0, rgba(CREAM, 1));
g.addColorStop(1, rgba(fog(PEACH, ba), 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ball.x, ball.y, r, 0, TAU);
ctx.fill();
} });
// 깊이 정렬 — 먼 것(깊이가 큰 것)부터 그려 앞의 것이 덮게 한다
items.sort(function (p: Paint, q: Paint): number { return q.z - p.z; });
for (let i = 0; i < items.length; i += 1) { items[i].paint(); }
}
function stateText(): string {
return pctText(prog) + ' — ' + '나선 ' + (prog * HELIX_TURNS).toFixed(1) + ' / ' + HELIX_TURNS + ' 바퀴';
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="공이 나선 계단을 돌아 내려가고, 먼 쪽 계단은 작고 흐리게 보이는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}05 · 나선 계단을 돌아 내려가는 공과 원근 — 측정 누적면적 47.66% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function project(x, y, z) {
const yy = y - camY;
const c = Math.cos(CAMERA_TILT);
const sn = Math.sin(CAMERA_TILT);
const y2 = yy * c - z * sn;
const z2 = yy * sn + z * c;
const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
}
06마디 길이를 지키며 길을 따라 끌려오는 꼬리
머리는 길 위 진행 길이 자리에 붙이고, 마디 12개는 지난 틱에 간 만큼에 0.9 를 곱해 이번 틱에도 간 뒤 이웃 거리를 15px 로 6번 되맞춘다. 시연 한 바퀴에서 마디 길이 오차는 최대 18.3% 에 머물지만 되맞춤을 1번으로 줄이면 88.1% 까지 늘어나 꼬리가 고무줄처럼 늘어지니, 브랜드 마스코트처럼 꼬리 모양이 캐릭터의 일부인 장면에서는 반복 횟수를 아끼지 않는 편이 낫다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="꼬리가 마디 길이를 지키며 길을 따라 끌려오는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 꼬리 마디 수 — 머리 뒤에 이어지는 점 개수
const TAIL_SEGMENTS = 12;
// 마디 길이(px) — 이웃 점 사이 거리를 이 값으로 되맞춘다
const SEGMENT_LENGTH = 15;
// 되맞춤 반복 — 틱마다 모든 마디 거리를 이 횟수만큼 다시 맞춘다(많을수록 덜 늘어난다)
const CONSTRAINT_ITERATIONS = 6;
// 끌림 — 이전 위치와의 차(= 지난 틱에 움직인 만큼)에 틱마다 곱한다
const TAIL_DRAG = 0.9;
const DEMO_SEED = 4746;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[40, 226], [110, 70], [210, 150], [290, 60], [400, 110], [440, 226], [320, 238], [230, 196]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
// 마디 위치(xs·ys)와 한 틱 전 위치(pxs·pys). 0 번이 머리
const xs = [];
const ys = [];
const pxs = [];
const pys = [];
function head() { return pointAtLength(TABLE, prog * TABLE.total); }
// 처음 자리 — 머리 뒤쪽 길 위에 마디 길이 간격으로 늘어놓는다. 길 시작보다 뒤면 시작점 접선 반대쪽으로 곧게
function onRestart() {
const s = prog * TABLE.total;
const a = pointAtLength(TABLE, 0);
const b = pointAtLength(TABLE, 4);
const d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
for (let i = 0; i <= TAIL_SEGMENTS; i += 1) {
const back = s - i * SEGMENT_LENGTH;
const q = back >= 0 ? pointAtLength(TABLE, back) : [a[0] + (a[0] - b[0]) / d * -back, a[1] + (a[1] - b[1]) / d * -back];
xs[i] = q[0];
ys[i] = q[1];
pxs[i] = q[0];
pys[i] = q[1];
}
}
function step(dp) {
const h = head();
xs[0] = h[0];
ys[0] = h[1];
// 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
const vy = (ys[i] - pys[i]) * TAIL_DRAG;
pxs[i] = xs[i];
pys[i] = ys[i];
xs[i] += vx;
ys[i] += vy;
}
// 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - SEGMENT_LENGTH) / d;
if (i === 1) {
xs[i] -= dx * diff;
ys[i] -= dy * diff;
}
else {
xs[i] -= dx * diff * 0.5;
ys[i] -= dy * diff * 0.5;
xs[i - 1] += dx * diff * 0.5;
ys[i - 1] += dy * diff * 0.5;
}
}
}
}
function draw() {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
// 몸통 — 꼬리 끝(가늘게)부터 머리(굵게) 쪽으로, 마디 사이를 굵은 선으로 이어 칠한다
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.strokeStyle = rgba(i % 2 ? PINK : PEACH, 1);
ctx.lineWidth = r * 2;
ctx.beginPath();
ctx.moveTo(xs[i], ys[i]);
ctx.lineTo(xs[i - 1], ys[i - 1]);
ctx.stroke();
}
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.fillStyle = rgba(BG, 0.35);
ctx.beginPath();
ctx.arc(xs[i], ys[i], r * 0.32, 0, TAU);
ctx.fill();
}
// 머리 — 1 번 마디에서 머리로 향하는 쪽을 본다
const ax = xs[0] - xs[1];
const ay = ys[0] - ys[1];
const ad = Math.hypot(ax, ay) || 1;
const fx = ax / ad;
const fy = ay / ad;
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(xs[0], ys[0], 16, 0, TAU);
ctx.fill();
for (let e = -1; e <= 1; e += 2) {
const ex = xs[0] + fx * 6 - fy * 7 * e;
const ey = ys[0] + fy * 6 + fx * 7 * e;
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(ex, ey, 4.5, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(ex + fx * 1.8, ey + fy * 1.8, 2.2, 0, TAU);
ctx.fill();
}
}
function stateText() {
const moving = Math.hypot(xs[TAIL_SEGMENTS] - pxs[TAIL_SEGMENTS], ys[TAIL_SEGMENTS] - pys[TAIL_SEGMENTS]) > 0.05;
return pctText(prog) + ' — ' + (moving ? '꼬리가 끌려오는 중' : '꼬리가 멈춤');
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// ChainTailLengthConstraint - vanilla 판(06-spa-chain-tail-length-constraint) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ChainTailLengthConstraint.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ChainTailLengthConstraint() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 꼬리 마디 수 — 머리 뒤에 이어지는 점 개수
const TAIL_SEGMENTS = 12;
// 마디 길이(px) — 이웃 점 사이 거리를 이 값으로 되맞춘다
const SEGMENT_LENGTH = 15;
// 되맞춤 반복 — 틱마다 모든 마디 거리를 이 횟수만큼 다시 맞춘다(많을수록 덜 늘어난다)
const CONSTRAINT_ITERATIONS = 6;
// 끌림 — 이전 위치와의 차(= 지난 틱에 움직인 만큼)에 틱마다 곱한다
const TAIL_DRAG = 0.9;
const DEMO_SEED = 4746;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[40, 226], [110, 70], [210, 150], [290, 60], [400, 110], [440, 226], [320, 238], [230, 196]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
// 마디 위치(xs·ys)와 한 틱 전 위치(pxs·pys). 0 번이 머리
const xs: number[] = [];
const ys: number[] = [];
const pxs: number[] = [];
const pys: number[] = [];
function head(): Pt { return pointAtLength(TABLE, prog * TABLE.total); }
// 처음 자리 — 머리 뒤쪽 길 위에 마디 길이 간격으로 늘어놓는다. 길 시작보다 뒤면 시작점 접선 반대쪽으로 곧게
function onRestart(): void {
const s = prog * TABLE.total;
const a = pointAtLength(TABLE, 0);
const b = pointAtLength(TABLE, 4);
const d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
for (let i = 0; i <= TAIL_SEGMENTS; i += 1) {
const back = s - i * SEGMENT_LENGTH;
const q: Pt = back >= 0 ? pointAtLength(TABLE, back) : [a[0] + (a[0] - b[0]) / d * -back, a[1] + (a[1] - b[1]) / d * -back];
xs[i] = q[0];
ys[i] = q[1];
pxs[i] = q[0];
pys[i] = q[1];
}
}
function step(dp: number): void {
const h = head();
xs[0] = h[0];
ys[0] = h[1];
// 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
const vy = (ys[i] - pys[i]) * TAIL_DRAG;
pxs[i] = xs[i];
pys[i] = ys[i];
xs[i] += vx;
ys[i] += vy;
}
// 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - SEGMENT_LENGTH) / d;
if (i === 1) {
xs[i] -= dx * diff;
ys[i] -= dy * diff;
} else {
xs[i] -= dx * diff * 0.5;
ys[i] -= dy * diff * 0.5;
xs[i - 1] += dx * diff * 0.5;
ys[i - 1] += dy * diff * 0.5;
}
}
}
}
function draw(): void {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
// 몸통 — 꼬리 끝(가늘게)부터 머리(굵게) 쪽으로, 마디 사이를 굵은 선으로 이어 칠한다
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.strokeStyle = rgba(i % 2 ? PINK : PEACH, 1);
ctx.lineWidth = r * 2;
ctx.beginPath();
ctx.moveTo(xs[i], ys[i]);
ctx.lineTo(xs[i - 1], ys[i - 1]);
ctx.stroke();
}
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.fillStyle = rgba(BG, 0.35);
ctx.beginPath();
ctx.arc(xs[i], ys[i], r * 0.32, 0, TAU);
ctx.fill();
}
// 머리 — 1 번 마디에서 머리로 향하는 쪽을 본다
const ax = xs[0] - xs[1];
const ay = ys[0] - ys[1];
const ad = Math.hypot(ax, ay) || 1;
const fx = ax / ad;
const fy = ay / ad;
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(xs[0], ys[0], 16, 0, TAU);
ctx.fill();
for (let e = -1; e <= 1; e += 2) {
const ex = xs[0] + fx * 6 - fy * 7 * e;
const ey = ys[0] + fy * 6 + fx * 7 * e;
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(ex, ey, 4.5, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(ex + fx * 1.8, ey + fy * 1.8, 2.2, 0, TAU);
ctx.fill();
}
}
function stateText(): string {
const moving = Math.hypot(xs[TAIL_SEGMENTS] - pxs[TAIL_SEGMENTS], ys[TAIL_SEGMENTS] - pys[TAIL_SEGMENTS]) > 0.05;
return pctText(prog) + ' — ' + (moving ? '꼬리가 끌려오는 중' : '꼬리가 멈춤');
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="꼬리가 마디 길이를 지키며 길을 따라 끌려오는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}06 · 마디 길이를 지키며 길을 따라 끌려오는 꼬리 — 측정 누적면적 22.145% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
const vy = (ys[i] - pys[i]) * TAIL_DRAG;
pxs[i] = xs[i];
pys[i] = ys[i];
xs[i] += vx;
ys[i] += vy;
}
// 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - SEGMENT_LENGTH) / d;
if (i === 1) {
xs[i] -= dx * diff;
ys[i] -= dy * diff;
}
else {
xs[i] -= dx * diff * 0.5;
ys[i] -= dy * diff * 0.5;
xs[i - 1] += dx * diff * 0.5;
ys[i - 1] += dy * diff * 0.5;
}
}
}
07같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선
별 10점과 하트 40점은 점 사이 간격이 제각각이라(하트는 2.07~20.71px) 둘레 길이를 따라 96점씩 같은 간격으로 다시 찍고, 96가지 돌림 가운데 짝지은 점의 거리 제곱 합이 가장 작은 48칸을 골라 시작점을 맞춘다. 맞추지 않으면 점 하나가 평균 137.6px 를 가로질러 모양 안에서 엇갈리지만 48칸을 돌리면 34.4px 로 줄고, 진행률 0.25~0.75 동안 모양은 무대 가운데 붙박인 채 섞여 요금제 비교 구간의 아이콘이 바뀌는 자리에 어울린다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="별 모양이 들어와 가운데 고정된 채 하트로 바뀌고 빠져나가는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 다시 찍는 점 수 — 두 외곽선을 둘 다 이 개수로, 같은 길이 간격마다 다시 찍는다
const MORPH_POINTS = 96;
// 고정 구간 — 진행률이 이 사이에 있는 동안 모양이 가운데 고정된 채 바뀐다(앞은 들어오기, 뒤는 빠져나가기)
const PIN_RANGE_START = 0.25;
const PIN_RANGE_END = 0.75;
// 모양 크기(px) — 별 바깥 꼭짓점까지
const SHAPE_R = 92;
const SHAPE_X = 254;
const DEMO_SEED = 4747;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 모양 바꾸기가 끝난 고정 구간 끝(1 이면 모양이 화면 밖으로 빠져나가 빈 무대만 남는다)
const STILL_PROGRESS = PIN_RANGE_END;
// 별 — 꼭짓점 10개(바깥·안쪽 번갈아), 위 꼭짓점에서 시작해 화면에서 시계 방향
function starOutline() {
const out = [];
for (let i = 0; i < 10; i += 1) {
const a = -Math.PI / 2 + i * Math.PI / 5;
const r = i % 2 ? SHAPE_R * 0.45 : SHAPE_R;
out.push([Math.cos(a) * r, Math.sin(a) * r]);
}
return out;
}
// 하트 — 매개변수 곡선을 40점으로. 점 간격이 고르지 않다(위 오목한 곳은 촘촘하고 옆구리는 성기다). 화면에서 시계 방향.
// 첫 점은 아래 꼭짓점(t = 180°)이라 별의 첫 점(위 꼭짓점)과 반대편이다 — 그대로 짝지으면 점들이 모양을 가로질러 엇갈린다
function heartOutline() {
const out = [];
const k = SHAPE_R / 17;
for (let i = 0; i < 40; i += 1) {
const t = (i / 40 + 0.5) * TAU;
const x = 16 * Math.pow(Math.sin(t), 3);
const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
out.push([x * k, (y - 4) * k]);
}
return out;
}
// 같은 길이 간격 다시 찍기 — 닫힌 외곽선의 누적 길이를 재고 전체 길이 / n 마다 점을 놓는다
function resample(poly, n) {
const m = poly.length;
const seg = [];
let total = 0;
for (let i = 0; i < m; i += 1) {
const a = poly[i];
const b = poly[(i + 1) % m];
const d = Math.hypot(b[0] - a[0], b[1] - a[1]);
seg.push(d);
total += d;
}
const out = [];
let i = 0;
let walked = 0;
for (let j = 0; j < n; j += 1) {
const want = j * total / n;
while (walked + seg[i] < want && i < m - 1) {
walked += seg[i];
i += 1;
}
const k = seg[i] > 0 ? (want - walked) / seg[i] : 0;
const a = poly[i];
const b = poly[(i + 1) % m];
out.push([lerp(a[0], b[0], k), lerp(a[1], b[1], k)]);
}
return out;
}
// 시작점 맞춤 — b 를 k 칸 돌려 짝지었을 때 거리 제곱 합이 가장 작은 k 를 고른다
function bestShift(a, b) {
const n = a.length;
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < n; k += 1) {
let cost = 0;
for (let i = 0; i < n; i += 1) {
const q = b[(i + k) % n];
cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
}
if (cost < bestCost) {
bestCost = cost;
best = k;
}
}
return best;
}
function rotateBy(b, k) {
const out = [];
for (let i = 0; i < b.length; i += 1) {
out.push(b[(i + k) % b.length]);
}
return out;
}
const SHAPE_A = resample(starOutline(), MORPH_POINTS);
const SHAPE_B_RAW = resample(heartOutline(), MORPH_POINTS);
const SHIFT = bestShift(SHAPE_A, SHAPE_B_RAW);
const SHAPE_B = rotateBy(SHAPE_B_RAW, SHIFT);
// 고정 구간 진행률 → 모양 섞는 비율
function morphT(p) { return smooth((p - PIN_RANGE_START) / (PIN_RANGE_END - PIN_RANGE_START)); }
// 세로 자리 — 고정 구간 앞에서는 아래에서 들어오고, 뒤에서는 위로 빠져나간다
function centerY(p) {
if (p < PIN_RANGE_START) {
return lerp(H + SHAPE_R + 20, H / 2, smooth(p / PIN_RANGE_START));
}
if (p > PIN_RANGE_END) {
return lerp(H / 2, -SHAPE_R - 20, smooth((p - PIN_RANGE_END) / (1 - PIN_RANGE_END)));
}
return H / 2;
}
function onRestart() { }
function step(dp) { }
function draw() {
clearStage();
const t = morphT(prog);
const cy = centerY(prog);
// 고정 구간 막대 — 왼쪽 세로 막대에 고정 구간을 칠하고 지금 진행률을 점으로
const top = 36;
const bot = 236;
ctx.lineCap = 'round';
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.beginPath();
ctx.moveTo(28, top);
ctx.lineTo(28, bot);
ctx.stroke();
ctx.lineWidth = 8;
ctx.strokeStyle = rgba(LILAC, 1);
ctx.beginPath();
ctx.moveTo(28, lerp(top, bot, PIN_RANGE_START));
ctx.lineTo(28, lerp(top, bot, PIN_RANGE_END));
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(28, lerp(top, bot, prog), 7, 0, TAU);
ctx.fill();
// 모양 — 짝지은 점끼리 섞는다
ctx.beginPath();
for (let i = 0; i < MORPH_POINTS; i += 1) {
const x = SHAPE_X + lerp(SHAPE_A[i][0], SHAPE_B[i][0], t);
const y = cy + lerp(SHAPE_A[i][1], SHAPE_B[i][1], t);
if (i === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
const g = ctx.createLinearGradient(SHAPE_X, cy - SHAPE_R, SHAPE_X, cy + SHAPE_R);
g.addColorStop(0, rgba(mixRGB(LEMON, PINK, t), 1));
g.addColorStop(1, rgba(mixRGB(PEACH, LILAC, t), 1));
ctx.fillStyle = g;
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CREAM, 1);
ctx.stroke();
// 다시 찍은 점 — 같은 길이 간격. 0 번 점(시작점)은 크게
for (let i = 0; i < MORPH_POINTS; i += 1) {
const x = SHAPE_X + lerp(SHAPE_A[i][0], SHAPE_B[i][0], t);
const y = cy + lerp(SHAPE_A[i][1], SHAPE_B[i][1], t);
ctx.fillStyle = rgba(i === 0 ? CREAM : BG, i === 0 ? 1 : 0.55);
ctx.beginPath();
ctx.arc(x, y, i === 0 ? 5 : 1.8, 0, TAU);
ctx.fill();
}
}
function stateText() {
let phase = '들어오는 중';
if (prog > PIN_RANGE_END) {
phase = '빠져나가는 중';
}
else if (prog >= PIN_RANGE_START) {
phase = '모양 바꾸는 중 ' + Math.round(morphT(prog) * 100) + '%';
}
return pctText(prog) + ' — ' + phase;
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// ShapeMorphArcResample - vanilla 판(07-spa-shape-morph-arc-resample) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ShapeMorphArcResample.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ShapeMorphArcResample() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 다시 찍는 점 수 — 두 외곽선을 둘 다 이 개수로, 같은 길이 간격마다 다시 찍는다
const MORPH_POINTS = 96;
// 고정 구간 — 진행률이 이 사이에 있는 동안 모양이 가운데 고정된 채 바뀐다(앞은 들어오기, 뒤는 빠져나가기)
const PIN_RANGE_START = 0.25;
const PIN_RANGE_END = 0.75;
// 모양 크기(px) — 별 바깥 꼭짓점까지
const SHAPE_R = 92;
const SHAPE_X = 254;
const DEMO_SEED = 4747;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 모양 바꾸기가 끝난 고정 구간 끝(1 이면 모양이 화면 밖으로 빠져나가 빈 무대만 남는다)
const STILL_PROGRESS = PIN_RANGE_END;
// 별 — 꼭짓점 10개(바깥·안쪽 번갈아), 위 꼭짓점에서 시작해 화면에서 시계 방향
function starOutline(): Pt[] {
const out: Pt[] = [];
for (let i = 0; i < 10; i += 1) {
const a = -Math.PI / 2 + i * Math.PI / 5;
const r = i % 2 ? SHAPE_R * 0.45 : SHAPE_R;
out.push([Math.cos(a) * r, Math.sin(a) * r]);
}
return out;
}
// 하트 — 매개변수 곡선을 40점으로. 점 간격이 고르지 않다(위 오목한 곳은 촘촘하고 옆구리는 성기다). 화면에서 시계 방향.
// 첫 점은 아래 꼭짓점(t = 180°)이라 별의 첫 점(위 꼭짓점)과 반대편이다 — 그대로 짝지으면 점들이 모양을 가로질러 엇갈린다
function heartOutline(): Pt[] {
const out: Pt[] = [];
const k = SHAPE_R / 17;
for (let i = 0; i < 40; i += 1) {
const t = (i / 40 + 0.5) * TAU;
const x = 16 * Math.pow(Math.sin(t), 3);
const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
out.push([x * k, (y - 4) * k]);
}
return out;
}
// 같은 길이 간격 다시 찍기 — 닫힌 외곽선의 누적 길이를 재고 전체 길이 / n 마다 점을 놓는다
function resample(poly: Pt[], n: number): Pt[] {
const m = poly.length;
const seg: number[] = [];
let total = 0;
for (let i = 0; i < m; i += 1) {
const a = poly[i];
const b = poly[(i + 1) % m];
const d = Math.hypot(b[0] - a[0], b[1] - a[1]);
seg.push(d);
total += d;
}
const out: Pt[] = [];
let i = 0;
let walked = 0;
for (let j = 0; j < n; j += 1) {
const want = j * total / n;
while (walked + seg[i] < want && i < m - 1) { walked += seg[i]; i += 1; }
const k = seg[i] > 0 ? (want - walked) / seg[i] : 0;
const a = poly[i];
const b = poly[(i + 1) % m];
out.push([lerp(a[0], b[0], k), lerp(a[1], b[1], k)]);
}
return out;
}
// 시작점 맞춤 — b 를 k 칸 돌려 짝지었을 때 거리 제곱 합이 가장 작은 k 를 고른다
function bestShift(a: Pt[], b: Pt[]): number {
const n = a.length;
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < n; k += 1) {
let cost = 0;
for (let i = 0; i < n; i += 1) {
const q = b[(i + k) % n];
cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
}
if (cost < bestCost) { bestCost = cost; best = k; }
}
return best;
}
function rotateBy(b: Pt[], k: number): Pt[] {
const out: Pt[] = [];
for (let i = 0; i < b.length; i += 1) { out.push(b[(i + k) % b.length]); }
return out;
}
const SHAPE_A: Pt[] = resample(starOutline(), MORPH_POINTS);
const SHAPE_B_RAW: Pt[] = resample(heartOutline(), MORPH_POINTS);
const SHIFT = bestShift(SHAPE_A, SHAPE_B_RAW);
const SHAPE_B: Pt[] = rotateBy(SHAPE_B_RAW, SHIFT);
// 고정 구간 진행률 → 모양 섞는 비율
function morphT(p: number): number { return smooth((p - PIN_RANGE_START) / (PIN_RANGE_END - PIN_RANGE_START)); }
// 세로 자리 — 고정 구간 앞에서는 아래에서 들어오고, 뒤에서는 위로 빠져나간다
function centerY(p: number): number {
if (p < PIN_RANGE_START) { return lerp(H + SHAPE_R + 20, H / 2, smooth(p / PIN_RANGE_START)); }
if (p > PIN_RANGE_END) { return lerp(H / 2, -SHAPE_R - 20, smooth((p - PIN_RANGE_END) / (1 - PIN_RANGE_END))); }
return H / 2;
}
function onRestart(): void { /* 진행률만으로 정해지는 장면 — 따로 쥐고 있는 상태가 없다 */ }
function step(dp: number): void { /* 같은 이유로 틱마다 할 일이 없다 */ }
function draw(): void {
clearStage();
const t = morphT(prog);
const cy = centerY(prog);
// 고정 구간 막대 — 왼쪽 세로 막대에 고정 구간을 칠하고 지금 진행률을 점으로
const top = 36;
const bot = 236;
ctx.lineCap = 'round';
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.beginPath();
ctx.moveTo(28, top);
ctx.lineTo(28, bot);
ctx.stroke();
ctx.lineWidth = 8;
ctx.strokeStyle = rgba(LILAC, 1);
ctx.beginPath();
ctx.moveTo(28, lerp(top, bot, PIN_RANGE_START));
ctx.lineTo(28, lerp(top, bot, PIN_RANGE_END));
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(28, lerp(top, bot, prog), 7, 0, TAU);
ctx.fill();
// 모양 — 짝지은 점끼리 섞는다
ctx.beginPath();
for (let i = 0; i < MORPH_POINTS; i += 1) {
const x = SHAPE_X + lerp(SHAPE_A[i][0], SHAPE_B[i][0], t);
const y = cy + lerp(SHAPE_A[i][1], SHAPE_B[i][1], t);
if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.closePath();
const g = ctx.createLinearGradient(SHAPE_X, cy - SHAPE_R, SHAPE_X, cy + SHAPE_R);
g.addColorStop(0, rgba(mixRGB(LEMON, PINK, t), 1));
g.addColorStop(1, rgba(mixRGB(PEACH, LILAC, t), 1));
ctx.fillStyle = g;
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CREAM, 1);
ctx.stroke();
// 다시 찍은 점 — 같은 길이 간격. 0 번 점(시작점)은 크게
for (let i = 0; i < MORPH_POINTS; i += 1) {
const x = SHAPE_X + lerp(SHAPE_A[i][0], SHAPE_B[i][0], t);
const y = cy + lerp(SHAPE_A[i][1], SHAPE_B[i][1], t);
ctx.fillStyle = rgba(i === 0 ? CREAM : BG, i === 0 ? 1 : 0.55);
ctx.beginPath();
ctx.arc(x, y, i === 0 ? 5 : 1.8, 0, TAU);
ctx.fill();
}
}
function stateText(): string {
let phase = '들어오는 중';
if (prog > PIN_RANGE_END) { phase = '빠져나가는 중'; }
else if (prog >= PIN_RANGE_START) { phase = '모양 바꾸는 중 ' + Math.round(morphT(prog) * 100) + '%'; }
return pctText(prog) + ' — ' + phase;
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="별 모양이 들어와 가운데 고정된 채 하트로 바뀌고 빠져나가는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}07 · 같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선 — 측정 누적면적 25.627% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 시작점 맞춤 — b 를 k 칸 돌려 짝지었을 때 거리 제곱 합이 가장 작은 k 를 고른다
function bestShift(a, b) {
const n = a.length;
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < n; k += 1) {
let cost = 0;
for (let i = 0; i < n; i += 1) {
const q = b[(i + k) % n];
cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
}
if (cost < bestCost) {
bestCost = cost;
best = k;
}
}
return best;
}
08스크롤 빠르기만큼 길 위를 흘러가는 점 무리
점 160개는 저마다 길 위 길이와 옆으로 비킨 거리를 따로 갖고, 굴려서 길 위로 움직인 px 를 0.2 비율로 고른 흐름 값에 이득 2.4 를 곱해 기본 흐름 0.5px 에 더한 만큼 매 틱 나아간다. 옆 거리는 시드 256칸 값 노이즈로 ±40px 안에서 흔들려 물살처럼 보이고, 시연 중 한 틱 전진이 최대 20.01px(기본의 40배)까지 올랐다가 되돌아갈 때는 −18.71px 로 거꾸로 흘러 데이터가 어느 쪽으로 오가는지 보여 주는 도식에 맞는다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="점 무리가 굴리는 빠르기만큼 길 위를 흘러가는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 점 수
const FLOW_DOTS = 160;
// 빠르기 이득 — 굴려서 길을 따라 움직인 거리(px/틱)에 곱해 점이 그만큼 더 간다(거꾸로 굴리면 거꾸로)
const FLOW_SPEED_GAIN = 2.4;
// 옆 퍼짐(px) — 길 가운데에서 양옆으로 이만큼까지
const FLOW_SPREAD = 40;
// 기본 흐름(px/틱) — 굴리지 않아도 이만큼은 흐른다
const BASE_FLOW = 0.5;
// 빠르기 저역 필터 — 틱마다 새 값과의 차에 이 비율만큼 다가간다
const FLOW_SMOOTH = 0.2;
// 옆 흔들림 노이즈 — 길이 1px 당·틱 1 당 노이즈 좌표 증가, 흔들림 폭(FLOW_SPREAD 에 대한 비율)
const JITTER_SCALE = 0.012;
const JITTER_TIME = 0.01;
const JITTER_AMP = 0.45;
const DEMO_SEED = 4748;
const DEMO_LEGS = 5;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[-30, 70], [110, 60], [230, 150], [350, 236], [510, 214]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
const DOT_COLORS = [SKY, PINK, MINT, LILAC, PEACH, LEMON];
// 값 노이즈 — 시드로 만든 256 칸 값 사이를 부드럽게 이어 −1~1 로 낸다
const NOISE = [];
{
const rng = makeRng(4880);
for (let i = 0; i < 256; i += 1) {
NOISE.push(rng() * 2 - 1);
}
}
function noise1(x) {
const f = Math.floor(x);
const i = ((f % 256) + 256) % 256;
return lerp(NOISE[i], NOISE[(i + 1) % 256], smooth(x - f));
}
const dots = [];
// 흐름 빠르기(px/틱, 부호 있음)와 노이즈 시계
let flow = 0;
let age = 0;
function onRestart() {
const rng = makeRng(4881);
dots.length = 0;
for (let i = 0; i < FLOW_DOTS; i += 1) {
dots.push({ s: rng() * TABLE.total, lane: rng() * 2 - 1, pace: 0.7 + rng() * 0.6, seed: rng() * 200, col: DOT_COLORS[i % DOT_COLORS.length] });
}
flow = 0;
age = 0;
}
// 한 틱에 가는 거리 = 기본 흐름 + 굴린 거리 × 이득
function advance() { return BASE_FLOW + flow * FLOW_SPEED_GAIN; }
function step(dp) {
flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
age += 1;
const adv = advance();
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
}
}
function sideOf(d, s) {
return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
}
function placeAt(s, side) {
const p = pointAtLength(TABLE, s);
const n = normalAt(TABLE, s);
return [p[0] + n[0] * side, p[1] + n[1] * side];
}
function draw() {
clearStage();
// 물길 가장자리 — 길에서 양옆으로 (퍼짐 + 10px)
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
for (let e = -1; e <= 1; e += 2) {
ctx.beginPath();
for (let s = 0; s <= TABLE.total; s += 8) {
const q = placeAt(s, e * (FLOW_SPREAD + 10));
if (s === 0) {
ctx.moveTo(q[0], q[1]);
}
else {
ctx.lineTo(q[0], q[1]);
}
}
ctx.stroke();
}
ctx.setLineDash([]);
ctx.lineCap = 'round';
ctx.lineWidth = 3;
const adv = advance();
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
// 꼬리 — 한 틱에 가는 거리에 비례해 뒤로 늘인다(거꾸로 흐르면 앞으로)
const streak = clamp(adv * d.pace * 2.2, -40, 40);
const s1 = d.s;
const s0 = clamp(s1 - streak - (streak >= 0 ? 2 : -2), 0, TABLE.total);
const a = placeAt(s0, sideOf(d, s0));
const b = placeAt(s1, sideOf(d, s1));
ctx.strokeStyle = rgba(d.col, 0.95);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
}
function stateText() {
return pctText(prog) + ' — ' + (Math.abs(flow) > 1 ? (flow > 0 ? '물살이 빨라짐' : '거꾸로 흐름') : '잔잔히 흐름');
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// FlowDotsSpeedGain - vanilla 판(08-spa-flow-dots-speed-gain) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlowDotsSpeedGain.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlowDotsSpeedGain() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 점 수
const FLOW_DOTS = 160;
// 빠르기 이득 — 굴려서 길을 따라 움직인 거리(px/틱)에 곱해 점이 그만큼 더 간다(거꾸로 굴리면 거꾸로)
const FLOW_SPEED_GAIN = 2.4;
// 옆 퍼짐(px) — 길 가운데에서 양옆으로 이만큼까지
const FLOW_SPREAD = 40;
// 기본 흐름(px/틱) — 굴리지 않아도 이만큼은 흐른다
const BASE_FLOW = 0.5;
// 빠르기 저역 필터 — 틱마다 새 값과의 차에 이 비율만큼 다가간다
const FLOW_SMOOTH = 0.2;
// 옆 흔들림 노이즈 — 길이 1px 당·틱 1 당 노이즈 좌표 증가, 흔들림 폭(FLOW_SPREAD 에 대한 비율)
const JITTER_SCALE = 0.012;
const JITTER_TIME = 0.01;
const JITTER_AMP = 0.45;
const DEMO_SEED = 4748;
const DEMO_LEGS = 5;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[-30, 70], [110, 60], [230, 150], [350, 236], [510, 214]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
const DOT_COLORS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH, LEMON];
// 값 노이즈 — 시드로 만든 256 칸 값 사이를 부드럽게 이어 −1~1 로 낸다
const NOISE: number[] = [];
{
const rng = makeRng(4880);
for (let i = 0; i < 256; i += 1) { NOISE.push(rng() * 2 - 1); }
}
function noise1(x: number): number {
const f = Math.floor(x);
const i = ((f % 256) + 256) % 256;
return lerp(NOISE[i], NOISE[(i + 1) % 256], smooth(x - f));
}
// 점 — s 는 길 위 길이, lane 은 옆 자리(−1~1), pace 는 점마다 다른 빠르기 배율, seed 는 노이즈 자리
type Dot = { s: number; lane: number; pace: number; seed: number; col: RGB };
const dots: Dot[] = [];
// 흐름 빠르기(px/틱, 부호 있음)와 노이즈 시계
let flow = 0;
let age = 0;
function onRestart(): void {
const rng = makeRng(4881);
dots.length = 0;
for (let i = 0; i < FLOW_DOTS; i += 1) {
dots.push({ s: rng() * TABLE.total, lane: rng() * 2 - 1, pace: 0.7 + rng() * 0.6, seed: rng() * 200, col: DOT_COLORS[i % DOT_COLORS.length] });
}
flow = 0;
age = 0;
}
// 한 틱에 가는 거리 = 기본 흐름 + 굴린 거리 × 이득
function advance(): number { return BASE_FLOW + flow * FLOW_SPEED_GAIN; }
function step(dp: number): void {
flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
age += 1;
const adv = advance();
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
}
}
function sideOf(d: Dot, s: number): number {
return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
}
function placeAt(s: number, side: number): Pt {
const p = pointAtLength(TABLE, s);
const n = normalAt(TABLE, s);
return [p[0] + n[0] * side, p[1] + n[1] * side];
}
function draw(): void {
clearStage();
// 물길 가장자리 — 길에서 양옆으로 (퍼짐 + 10px)
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
for (let e = -1; e <= 1; e += 2) {
ctx.beginPath();
for (let s = 0; s <= TABLE.total; s += 8) {
const q = placeAt(s, e * (FLOW_SPREAD + 10));
if (s === 0) { ctx.moveTo(q[0], q[1]); } else { ctx.lineTo(q[0], q[1]); }
}
ctx.stroke();
}
ctx.setLineDash([]);
ctx.lineCap = 'round';
ctx.lineWidth = 3;
const adv = advance();
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
// 꼬리 — 한 틱에 가는 거리에 비례해 뒤로 늘인다(거꾸로 흐르면 앞으로)
const streak = clamp(adv * d.pace * 2.2, -40, 40);
const s1 = d.s;
const s0 = clamp(s1 - streak - (streak >= 0 ? 2 : -2), 0, TABLE.total);
const a = placeAt(s0, sideOf(d, s0));
const b = placeAt(s1, sideOf(d, s1));
ctx.strokeStyle = rgba(d.col, 0.95);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
}
function stateText(): string {
return pctText(prog) + ' — ' + (Math.abs(flow) > 1 ? (flow > 0 ? '물살이 빨라짐' : '거꾸로 흐름') : '잔잔히 흐름');
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="점 무리가 굴리는 빠르기만큼 길 위를 흘러가는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}08 · 스크롤 빠르기만큼 길 위를 흘러가는 점 무리 — 측정 누적면적 24.613% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step(dp) {
flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
age += 1;
const adv = advance();
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
}
}
function sideOf(d, s) {
return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
}
09지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정
01 의 선 긋기, 02 의 접선 쫓기, 03 의 스프링 핀을 길이 695.1px 인 경로 하나에 묶는다. 상자는 접선 방향으로 몸을 돌리지 않고, 감쇠로 쫓는 접선 각이 이번 틱에 돈 만큼에 8 을 곱해 굽이 안쪽으로 기울 뿐이며 그 기울기는 ±0.35 rad(20.1°)를 넘지 않는다. 남은 거리는 12.6km × (1 − 간 길이 ÷ 전체 길이)로 세기 때문에 u 를 고르게 나눠 세운 정거장 넷이 남은 12.6·8.66·4.10·0km 처럼 고르지 않은 간격으로 지나가고, 상태 줄은 「출발 준비」에서 「출발지 지남」·「정거장 1 지남」처럼 지난 정거장 이름 뒤에 「지남」이 붙은 글자를 거쳐 「도착했어요」로 바뀐다.

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="상자가 지도 위 경로를 달리며 남은 거리를 세고, 정거장마다 핀이 솟는 배송 추적 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">진행 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { 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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
function makeIcon(back, front, under) {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon, x, y, angle, size, ox, oy, back, front, sy, alpha) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 경로 전체 거리(km) — 남은 거리 숫자 = 이 값 × (1 − 간 길이 / 전체 길이)
const ROUTE_TOTAL_KM = 12.6;
// 정거장 수 — 출발지·도착지 포함. u 를 고르게 나눈 자리에 두고 그 자리의 누적 길이를 미리 잰다
const STOP_COUNT = 4;
// 상자 기울기 상한(라디안) — 도는 빠르기에 비례해 굽이 안쪽으로 기울되 이 값을 넘지 않는다
const BOX_TILT_MAX = 0.35;
// 도는 빠르기(라디안/틱) → 기울기 배율
const TILT_GAIN = 8;
// 01·02·03 의 계산을 한 길에 — 길이 표 토막 수 · 앞을 내다보는 거리 · 방향 감쇠 · 핀 스프링 세기·감쇠
const ROUTE_SAMPLES = 200;
const ROUTE_LOOKAHEAD = 16;
const ROUTE_TURN_SMOOTH = 0.14;
const STOP_STIFFNESS = 0.12;
const STOP_DAMPING = 0.8;
const BOX_SIZE = 40;
const PIN_SIZE = 34;
const DEMO_SEED = 4749;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[48, 226], [64, 140], [150, 112], [226, 196], [318, 222], [362, 128], [330, 66], [446, 52]];
const TABLE = buildLengthTable(makeCurve(ROUTE), ROUTE_SAMPLES);
const BOX = makeIcon('M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.18a8 8 0 0 1-4.16-7V80.18a8 8 0 0 1 .7-3.25Z', 'm223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44l-29.77 16.3l-80.35-44Zm0 88L47.66 76l33.9-18.56l80.34 44ZM40 90l80 43.78v85.79l-80-43.75Zm176 85.78l-80 43.79v-85.75l32-17.51V152a8 8 0 0 0 16 0v-44.45L216 90v85.77Z', 'M128 24L224 76V180L128 236L32 180V76Z');
const PIN = makeIcon('M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32', 'M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118', '');
const STOPS = [];
for (let i = 0; i < STOP_COUNT; i += 1) {
const s = lengthAtU(TABLE, i / (STOP_COUNT - 1));
STOPS.push({ s: s, km: ROUTE_TOTAL_KM * s / TABLE.total, x: 0, v: 0, up: false });
}
const BLOCKS = [];
{
const rng = makeRng(4790);
for (let gx = 0; gx < 8; gx += 1) {
for (let gy = 0; gy < 5; gy += 1) {
BLOCKS.push([8 + gx * 60 + rng() * 8, 8 + gy * 60 + rng() * 8, 38 + rng() * 10, 38 + rng() * 10]);
}
}
}
function stopName(i) {
if (i === 0) {
return '출발지';
}
if (i === STOP_COUNT - 1) {
return '도착지';
}
return '정거장 ' + i;
}
let heading = 0;
let target = 0;
let turn = 0;
let dir = 1;
let tilt = 0;
function travel() { return prog * TABLE.total; }
function remainingKm() { return ROUTE_TOTAL_KM * (1 - travel() / TABLE.total); }
function aimAt(s) {
const a = pointAtLength(TABLE, s);
const b = pointAtLength(TABLE, s + dir * ROUTE_LOOKAHEAD);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.hypot(dx, dy) < 0.5) {
return target;
}
return Math.atan2(dy, dx);
}
function onRestart() {
dir = 1;
target = aimAt(travel());
heading = target;
turn = 0;
tilt = 0;
const s = travel();
for (let i = 0; i < STOPS.length; i += 1) {
STOPS[i].up = s >= STOPS[i].s;
STOPS[i].x = STOPS[i].up ? 1 : 0;
STOPS[i].v = 0;
}
}
function step(dp) {
if (dp > 1e-6) {
dir = 1;
}
else if (dp < -1e-6) {
dir = -1;
}
// 02 — 접선 각을 가장 짧은 방향으로 감쇠해 쫓고, 이번 틱에 돈 각에 비례해 상자를 굽이 안쪽으로 기울인다
target = aimAt(travel());
turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
heading = wrapAngle(heading + turn);
tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
// 03 — 정거장 길이를 넘으면 핀이 스프링으로 솟고, 되돌리면 다시 눌린다
const s = travel();
for (let i = 0; i < STOPS.length; i += 1) {
const st = STOPS[i];
st.up = s >= st.s;
st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
st.v *= STOP_DAMPING;
st.x += st.v;
}
}
function phase() {
const s = travel();
if (s < 1) {
return '출발 준비';
}
if (s >= TABLE.total - 1) {
return '도착했어요';
}
let last = 0;
for (let i = 0; i < STOPS.length; i += 1) {
if (STOPS[i].up) {
last = i;
}
}
return stopName(last) + ' 지남';
}
function roundRectPath(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function draw() {
clearStage();
ctx.fillStyle = rgba(GUIDE, 0.32);
for (let i = 0; i < BLOCKS.length; i += 1) {
const b = BLOCKS[i];
roundRectPath(b[0], b[1], b[2], b[3], 6);
ctx.fill();
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 01 — 전체 길은 흐리게, 간 길이까지만 진하게 긋는다
tracePath(TABLE, 0, TABLE.total, 600);
ctx.lineWidth = 12;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.stroke();
const s = travel();
if (s > 0.5) {
tracePath(TABLE, 0, s, 600);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(MINT, 1);
ctx.stroke();
}
for (let i = 0; i < STOPS.length; i += 1) {
const st = STOPS[i];
const q = pointAtLength(TABLE, st.s);
const sy = clamp(0.25 + 0.75 * st.x, 0.1, 1.4);
drawIcon(PIN, q[0], q[1], 0, PIN_SIZE, 128, 232, st.up ? MINT : mixRGB(GUIDE, CREAM, 0.3), CREAM, sy, 1);
ctx.fillStyle = rgba(CREAM, 0.85);
ctx.font = '600 10px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(stopName(i), q[0], q[1] + 6);
}
// 상자 — 길 위 점에 바닥을 붙이고 기울기만큼 돌린다(방향은 서 있는 그대로)
const p = pointAtLength(TABLE, s);
ctx.fillStyle = rgba([0, 0, 0], 0.32);
ctx.beginPath();
ctx.ellipse(p[0] + 3, p[1] + 2, 15, 4.5, 0, 0, TAU);
ctx.fill();
drawIcon(BOX, p[0], p[1], tilt, BOX_SIZE, 128, 236, PEACH, CREAM, 1, 1);
// 남은 거리 판
roundRectPath(14, 14, 160, 58, 12);
ctx.fillStyle = rgba(CREAM, 0.94);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.font = '600 11px ' + FONT_FAMILY;
ctx.fillText('남은 거리', 26, 34);
ctx.font = '800 22px ' + FONT_FAMILY;
ctx.fillText(remainingKm().toFixed(1) + ' km', 26, 61);
}
function stateText() {
return pctText(prog) + ' — ' + '남은 거리 ' + remainingKm().toFixed(1) + ' km · ' + phase();
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// DeliveryRouteJourneyHero - vanilla 판(09-spa-delivery-route-journey-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./DeliveryRouteJourneyHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DeliveryRouteJourneyHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 아이콘 — Phosphor Duotone(MIT) SVG 의 두 경로(옅은 면 back · 윤곽 front)를 그대로 Path2D 로 옮겨 캔버스에 찍는다. 좌표는 256 칸.
// under 는 윤곽 안을 메우는 바탕(그 아이콘의 바깥 꼭짓점을 이은 것) — 옅은 면이 일부만 덮는 아이콘에만 쓴다
type Icon = { back: Path2D; front: Path2D; under: Path2D | null };
function makeIcon(back: string, front: string, under: string): Icon {
return { back: new Path2D(back), front: new Path2D(front), under: under ? new Path2D(under) : null };
}
// (x, y) 에 아이콘의 (ox, oy) 점을 붙이고 angle 만큼 돌린 뒤 한 변 size(px) 로 그린다. sy 는 세로 배율, alpha 는 투명도
function drawIcon(icon: Icon, x: number, y: number, angle: number, size: number, ox: number, oy: number, back: RGB, front: RGB, sy: number, alpha: number): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const k = size / 256;
ctx.scale(k, k * sy);
ctx.translate(-ox, -oy);
if (icon.under) {
ctx.fillStyle = rgba(mixRGB(back, CREAM, 0.35), alpha);
ctx.fill(icon.under);
}
ctx.fillStyle = rgba(back, alpha);
ctx.fill(icon.back);
ctx.fillStyle = rgba(front, alpha);
ctx.fill(icon.front);
ctx.restore();
}
// 경로 전체 거리(km) — 남은 거리 숫자 = 이 값 × (1 − 간 길이 / 전체 길이)
const ROUTE_TOTAL_KM = 12.6;
// 정거장 수 — 출발지·도착지 포함. u 를 고르게 나눈 자리에 두고 그 자리의 누적 길이를 미리 잰다
const STOP_COUNT = 4;
// 상자 기울기 상한(라디안) — 도는 빠르기에 비례해 굽이 안쪽으로 기울되 이 값을 넘지 않는다
const BOX_TILT_MAX = 0.35;
// 도는 빠르기(라디안/틱) → 기울기 배율
const TILT_GAIN = 8;
// 01·02·03 의 계산을 한 길에 — 길이 표 토막 수 · 앞을 내다보는 거리 · 방향 감쇠 · 핀 스프링 세기·감쇠
const ROUTE_SAMPLES = 200;
const ROUTE_LOOKAHEAD = 16;
const ROUTE_TURN_SMOOTH = 0.14;
const STOP_STIFFNESS = 0.12;
const STOP_DAMPING = 0.8;
const BOX_SIZE = 40;
const PIN_SIZE = 34;
const DEMO_SEED = 4749;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[48, 226], [64, 140], [150, 112], [226, 196], [318, 222], [362, 128], [330, 66], [446, 52]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), ROUTE_SAMPLES);
const BOX: Icon = makeIcon(
'M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.18a8 8 0 0 1-4.16-7V80.18a8 8 0 0 1 .7-3.25Z',
'm223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44l-29.77 16.3l-80.35-44Zm0 88L47.66 76l33.9-18.56l80.34 44ZM40 90l80 43.78v85.79l-80-43.75Zm176 85.78l-80 43.79v-85.75l32-17.51V152a8 8 0 0 0 16 0v-44.45L216 90v85.77Z',
'M128 24L224 76V180L128 236L32 180V76Z');
const PIN: Icon = makeIcon(
'M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32',
'M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118',
'');
// 정거장 — s 는 미리 잰 누적 길이, km 는 그 자리까지의 거리, x·v 는 핀 스프링(0 눌림 · 1 솟음)
type Stop = { s: number; km: number; x: number; v: number; up: boolean };
const STOPS: Stop[] = [];
for (let i = 0; i < STOP_COUNT; i += 1) {
const s = lengthAtU(TABLE, i / (STOP_COUNT - 1));
STOPS.push({ s: s, km: ROUTE_TOTAL_KM * s / TABLE.total, x: 0, v: 0, up: false });
}
// 지도 구역 — 시드로 크기만 조금씩 다른 네모 블록(길 이름·지명 없음)
type Block = [number, number, number, number];
const BLOCKS: Block[] = [];
{
const rng = makeRng(4790);
for (let gx = 0; gx < 8; gx += 1) {
for (let gy = 0; gy < 5; gy += 1) {
BLOCKS.push([8 + gx * 60 + rng() * 8, 8 + gy * 60 + rng() * 8, 38 + rng() * 10, 38 + rng() * 10]);
}
}
}
function stopName(i: number): string {
if (i === 0) { return '출발지'; }
if (i === STOP_COUNT - 1) { return '도착지'; }
return '정거장 ' + i;
}
let heading = 0;
let target = 0;
let turn = 0;
let dir = 1;
let tilt = 0;
function travel(): number { return prog * TABLE.total; }
function remainingKm(): number { return ROUTE_TOTAL_KM * (1 - travel() / TABLE.total); }
function aimAt(s: number): number {
const a = pointAtLength(TABLE, s);
const b = pointAtLength(TABLE, s + dir * ROUTE_LOOKAHEAD);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.hypot(dx, dy) < 0.5) { return target; }
return Math.atan2(dy, dx);
}
function onRestart(): void {
dir = 1;
target = aimAt(travel());
heading = target;
turn = 0;
tilt = 0;
const s = travel();
for (let i = 0; i < STOPS.length; i += 1) {
STOPS[i].up = s >= STOPS[i].s;
STOPS[i].x = STOPS[i].up ? 1 : 0;
STOPS[i].v = 0;
}
}
function step(dp: number): void {
if (dp > 1e-6) { dir = 1; } else if (dp < -1e-6) { dir = -1; }
// 02 — 접선 각을 가장 짧은 방향으로 감쇠해 쫓고, 이번 틱에 돈 각에 비례해 상자를 굽이 안쪽으로 기울인다
target = aimAt(travel());
turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
heading = wrapAngle(heading + turn);
tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
// 03 — 정거장 길이를 넘으면 핀이 스프링으로 솟고, 되돌리면 다시 눌린다
const s = travel();
for (let i = 0; i < STOPS.length; i += 1) {
const st = STOPS[i];
st.up = s >= st.s;
st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
st.v *= STOP_DAMPING;
st.x += st.v;
}
}
function phase(): string {
const s = travel();
if (s < 1) { return '출발 준비'; }
if (s >= TABLE.total - 1) { return '도착했어요'; }
let last = 0;
for (let i = 0; i < STOPS.length; i += 1) { if (STOPS[i].up) { last = i; } }
return stopName(last) + ' 지남';
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(GUIDE, 0.32);
for (let i = 0; i < BLOCKS.length; i += 1) {
const b = BLOCKS[i];
roundRectPath(b[0], b[1], b[2], b[3], 6);
ctx.fill();
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 01 — 전체 길은 흐리게, 간 길이까지만 진하게 긋는다
tracePath(TABLE, 0, TABLE.total, 600);
ctx.lineWidth = 12;
ctx.strokeStyle = rgba(GUIDE, 1);
ctx.stroke();
const s = travel();
if (s > 0.5) {
tracePath(TABLE, 0, s, 600);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(MINT, 1);
ctx.stroke();
}
for (let i = 0; i < STOPS.length; i += 1) {
const st = STOPS[i];
const q = pointAtLength(TABLE, st.s);
const sy = clamp(0.25 + 0.75 * st.x, 0.1, 1.4);
drawIcon(PIN, q[0], q[1], 0, PIN_SIZE, 128, 232, st.up ? MINT : mixRGB(GUIDE, CREAM, 0.3), CREAM, sy, 1);
ctx.fillStyle = rgba(CREAM, 0.85);
ctx.font = '600 10px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(stopName(i), q[0], q[1] + 6);
}
// 상자 — 길 위 점에 바닥을 붙이고 기울기만큼 돌린다(방향은 서 있는 그대로)
const p = pointAtLength(TABLE, s);
ctx.fillStyle = rgba([0, 0, 0], 0.32);
ctx.beginPath();
ctx.ellipse(p[0] + 3, p[1] + 2, 15, 4.5, 0, 0, TAU);
ctx.fill();
drawIcon(BOX, p[0], p[1], tilt, BOX_SIZE, 128, 236, PEACH, CREAM, 1, 1);
// 남은 거리 판
roundRectPath(14, 14, 160, 58, 12);
ctx.fillStyle = rgba(CREAM, 0.94);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.font = '600 11px ' + FONT_FAMILY;
ctx.fillText('남은 거리', 26, 34);
ctx.font = '800 22px ' + FONT_FAMILY;
ctx.fillText(remainingKm().toFixed(1) + ' km', 26, 61);
}
function stateText(): string {
return pctText(prog) + ' — ' + '남은 거리 ' + remainingKm().toFixed(1) + ' km · ' + phase();
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="상자가 지도 위 경로를 달리며 남은 거리를 세고, 정거장마다 핀이 솟는 배송 추적 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}09 · 지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정 — 측정 누적면적 12.894% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step(dp) {
if (dp > 1e-6) {
dir = 1;
}
else if (dp < -1e-6) {
dir = -1;
}
// 02 — 접선 각을 가장 짧은 방향으로 감쇠해 쫓고, 이번 틱에 돈 각에 비례해 상자를 굽이 안쪽으로 기울인다
target = aimAt(travel());
turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
heading = wrapAngle(heading + turn);
tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
// 03 — 정거장 길이를 넘으면 핀이 스프링으로 솟고, 되돌리면 다시 눌린다
const s = travel();
for (let i = 0; i < STOPS.length; i += 1) {
const st = STOPS[i];
st.up = s >= st.s;
st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
st.v *= STOP_DAMPING;
st.x += st.v;
}
}
진행률을 그대로 곡선 매개변수에 넣으면 왜 굽은 곳에서 빨라지고 느려지는가?
토막마다 같은 몫의 u 를 받는 곡선
이 편의 길은 makeCurve 가 지나는 점을 이어 만든 3차 곡선이다. 점과 점 사이 한 토막의 모양은 이웃 네 점으로 정하고, 곡선 변수 u 는 토막의 길이와 상관없이 토막마다 같은 몫을 나눠 받는다. 그래서 긴 토막에서는 u 가 조금만 움직여도 펜 끝이 멀리 가고, 짧은 토막이나 급히 휘는 곳에서는 같은 u 변화가 몇 px 에 그친다. 진행률이 일정하게 늘어도 화면 위 빠르기가 갈라지는 까닭이 여기에 있다.
걸음 400번으로 잰 펜 끝 빠르기
01 의 길에서 진행률을 같은 간격으로 400번 올리고, 4,000 토막으로 잰 참 길이와 비교해 펜 끝이 한 걸음에 나아간 거리를 모았다. 길 전체는 1,261.8px 이고 200 토막 표가 잰 값은 1,261.1px 이다.
| 진행률을 위치로 바꾸는 법 | 걸음당 최소 | 걸음당 최대 | 최대 ÷ 최소 | 변동계수 |
|---|---|---|---|---|
| u 에 그대로 넣기 | 0.842px | 5.191px | 6.16배 | 36.49% |
| 24 토막 길이 표 | 1.252px | 5.365px | 4.29배 | 25.24% |
| 200 토막 길이 표 | 3.047px | 3.267px | 1.07배 | 1.12% |
u 를 그대로 넣어도 u 0.25·0.5·0.75 는 길 전체의 23.3%·47.7%·76.6% 지점이라 뭉뚱그려 보면 엇비슷하다. 문제는 그 사이의 걸음 하나하나가 6배 넘게 벌어진다는 데 있고, 눈은 평균보다 그 덜컹거림을 먼저 알아챈다. 페이지 전체를 미끄러지게 하는 부드러운 스크롤 효과를 켜 두어도 이 덜컹거림은 남는다. 진행률이 아무리 매끄럽게 들어와도 그것을 u 로 옮기는 단계에서 빠르기가 갈라지기 때문이다. SVG <path> 라면 getTotalLength·getPointAtLength 가 길이 재기를 대신 해 주지만(SVG 경로 길이 재기 설명), 캔버스에는 그런 함수가 없어서 표를 직접 만든다.
곡선을 토막 내 누적 길이 표를 만들고, 진행률을 길이로 바꿔 점과 접선을 찾는 계산
표는 처음에 한 번, 찾기는 매 틱 이분 탐색
buildLengthTable 은 u 를 200 토막으로 잘라 토막 끝 점까지의 직선 거리를 차례로 더해 둔다. 장면이 시작할 때 한 번만 하는 일이고, 그 뒤로 매 틱 하는 일은 uAtLength 가 길이 s 가 들어가는 토막을 이분 탐색으로 찾는 것뿐이다. 201칸짜리 표라 비교는 8번 안에 끝나고, 찾은 토막 안에서는 길이에 비례해 u 를 나눈다. 01 은 진행률을 DRAW_START·DRAW_END 사이로 잘라 0~1 로 다시 늘린 값에 전체 길이를 곱해 그을 길이를 정하고, 그릴 때는 tracePath 가 u 를 600 걸음으로 잘게 따라가므로 표의 토막 수가 줄어도 선 자체는 매끈하게 남는다.
같은 표에서 접선과 법선을 꺼내는 법
표가 있으면 길이 s 의 점을 바로 얻으니 방향도 길이로 잰다. 02 와 09 는 s 와 s + 18px(09 는 16px) 두 점의 차를 atan2 에 넣어 접선 각을 얻고, 두 점이 0.5px 안으로 붙는 길 끝에서는 이전 각을 그대로 둔다. 04 의 normalAt 은 s 앞뒤 1px 두 점의 차를 90° 돌려 법선을 만들고, 물결은 그 방향으로 비킨다. 03 과 09 는 거꾸로 u → 길이 방향의 lengthAtU 를 써서 이정표와 정거장이 길 위 몇 px 에 있는지 미리 재 둔다.
길이 표를 쓰지 않는 두 장면
05 의 나선은 각도와 높이가 둘 다 진행률에 정비례하는 고른 나선이라 길이를 잴 필요가 없다. 07 은 곡선이 아니라 점을 이은 다각형 외곽선이므로 resample 이 변 길이를 직접 더해 둘레를 재고 같은 간격마다 점을 다시 찍는다. 나머지 일곱 장면, 01~04·06·08·09 가 같은 표 만들기와 찾기를 함께 쓴다.
토막 수를 줄이고 출렁임을 키우고 꼬리를 늘린 세 가지 변형
| 이름 | 값 | 느낌 |
|---|---|---|
| 길이 표 토막 줄이기 | 01 PATH_SAMPLES 200 → 24 | 200 이면 진행률을 같은 걸음으로 올릴 때 펜 끝이 걸음마다 3.05~3.27px(1.07배 안)로 고르게 나아간다. 24 로 줄이면 표 토막 하나가 길어져 걸음마다 1.25~5.37px(4.3배)로 벌어지고, 급한 굽이에서 선이 잠깐 앞질러 가다 멈칫한다 |
| 출렁임 폭 키우기 | 04 WOBBLE_AMP_MAX 14 → 36 | 14 면 가장 빠를 때 표시점 둘레의 물결이 법선 방향으로 14px 비킨다. 36 으로 키우면 36px 로 크게 굽이치고, 멈춘 뒤 빠르기 필터가 잦아드는 시간(464ms)은 같아도 물결이 1px 아래로 가라앉기까지 480ms 에서 656ms 로 늘어난다 |
| 꼬리 마디 늘리기 | 06 TAIL_SEGMENTS 12 → 30 | 12 면 180px 꼬리가 머리 뒤를 따라오고 시연 중 마디 길이 오차는 최대 18.3% 다. 30 으로 늘리면 450px 로 길어져 꼬리 끝이 길에서 최대 447.5px 벗어나며 굽이 안쪽을 크게 가로지르고, 같은 6번 되맞춤으로는 마디 오차가 최대 36.4% 로 커진다 |
세 값은 서로 다른 것을 판다. 토막 수는 표를 만드는 품을 줄이는 대신 고른 빠르기를 내주고, 출렁임 폭은 굴린 기세를 크게 보여 주는 대신 멈춘 뒤 가라앉는 시간을 늘린다. 꼬리를 늘리면 꼬리 끝이 길에서 멀리 벗어나므로, 마디 수를 30 처럼 키울 때는 CONSTRAINT_ITERATIONS 도 함께 올려 오차를 다시 재 보는 편이 낫다.
날카롭게 꺾인 길·거꾸로 스크롤·한 번에 멀리 튄 휠 — 길 따라가기가 깨지는 곳
꺾인 굽이에서 토막이 모자랄 때
24 토막 표로 줄이면 펜 끝 빠르기가 4.29배까지 벌어질 뿐 아니라 표가 잰 길 전체도 1,242.9px 로 참 길이보다 18.9px 짧아진다. 토막 끝을 잇는 직선은 곡선의 휜 몸통을 질러가기 때문이고, 굽이가 급할수록 한 토막 안에서 u 와 길이가 비례하지 않아 그 안에서 빠르기가 다시 출렁인다.
거꾸로 굴리는 순간 뒤집히는 접선
02 와 09 는 방향이 바뀌는 순간 앞선 점이 뒤쪽으로 넘어가 접선 목표가 177.6° 뒤집힌다. TURN_SMOOTH 를 1 로 두면 비행기는 같은 자리에서 한 틱 16ms 만에 반 바퀴를 휙 돌고, 0.14 면 640ms 에 걸쳐 돈다. 앞선 거리 FOLLOW_LOOKAHEAD 를 4px 로 줄이면 5° 안으로 돌아오기까지 1,680ms 가 걸려 오히려 굼떠졌으니, 앞을 가까이 본다고 방향을 빨리 바로잡는 것은 아니다.
휠 한 번에 진행률이 크게 뛸 때
End 키처럼 진행률이 한 틱에 크게 들어오면 04 는 빠르기 수준이 곧장 상한으로 뛰어 상태 줄에 「빠르게 굴리는 중」이 뜬다. 06 은 머리가 순간이동하듯 옮겨 가 6번 되맞춤으로는 다 못 따라가고 마디가 늘어난 채 끌려온다. 03 은 이정표 여럿을 한 번에 지나면 깃발이 저마다 따로 출렁이는데, 감쇠를 0.8 에서 0.95 로 올리면 넘침이 29.2° 에서 71.7° 로, 정착이 544ms 에서 2,368ms 로 늘어나 깃발들이 한참 동안 흔들린다.
길이 표·접선 쫓기·마디 되맞춤을 그대로 담은 아홉 장면의 index.html 과 같은 계산을 옮긴 리액트 판은 zip 하나에 들어 있고, 압축은 tmvtd6sv 을 넣으면 풀린다.
무대 위에서 굴린 휠은 페이지로 넘어가지 않는다
상자에는 overscroll-behavior: contain 이 걸려 있어 포인터가 무대 위에 있는 동안의 휠은 상자만 굴리고, 상자가 끝에 닿아도 나머지 휠이 페이지로 넘어가지 않는다. 휠을 가로채는 코드는 없지만 무대 위의 휠은 상자 몫이라, 글을 읽다 무대 위에서 휠을 굴린 사람은 페이지가 멈춘 것처럼 느낄 수 있다. 페이지를 계속 내리려면 포인터를 무대 밖으로 옮겨야 하므로, 실제 페이지에 넣을 때는 무대 높이를 화면의 일부로 묶어 두는 편이 안전해 보인다. 진행률은 상자 높이의 다섯 배를 기준으로 재므로 320px 폭처럼 무대가 작아지면 같은 휠 한 칸이 진행률을 더 많이 움직이고, 창 크기가 바뀔 때는 placeScroll 이 진행률을 지킨 채 상자를 다시 굴려 둔다.
카메라에 바짝 붙은 점
05 의 배율은 360 ÷ (360 + 깊이) 라서 깊이가 −360 에 가까워질수록 나누는 값이 0 으로 다가가 판이 터지듯 커진다. 지금 배치에서 가장 가까운 깊이는 −119 라 배율이 1.496 에서 그친다. 판마다 평균 깊이 하나로 정렬하므로 판이 길게 겹치는 배치에서는 앞뒤가 뒤바뀔 수 있어 보이지만, 판이 서로 꿰뚫지 않는 지금 배치에서는 눈에 띄는 어긋남이 없었다.
페이지 스크롤은 가로채지 않는다 — 무대 안 스크롤 상자와 움직임 줄이기 접근성
아홉 장면 모두 window 에 다는 리스너는 resize 하나뿐이고, 스크롤·휠 리스너는 무대 안 상자에만 단다. 휠 리스너는 { passive: true } 로 시연을 멈추는 일만 하고 preventDefault 를 부르지 않으며, position: sticky 로 페이지를 붙잡아 두는 구간도 없다. 휠을 가로채 세로 굴림을 가로 진행으로 바꾸는 방식은 가로 스크롤 9종 이 다뤘고, 이 편은 그 반대 길을 골랐다.
키보드로 굴리는 진행률과 상태 줄
상자는 tabindex="0"·role="region" 에 장면과 조작법을 적은 aria-label 을 갖는다. 초점이 오면 ↓·→ 는 4% 앞으로, ↑·← 는 4% 뒤로, PageDown·PageUp 은 25% 씩, Home 은 처음, End 는 끝으로 진행률을 옮기고, preventDefault 는 이 키들에만 걸려 Space 같은 나머지 키는 상자 안에서 원래대로 동작한다. 상태 줄은 시연 중 aria-live="off" 로 조용히 있다가 첫 입력에서 polite 로 바뀌고, 「진행 30%」 처럼 5% 단위로만 숫자를 고쳐 굴릴 때마다 낭독이 쏟아지지 않게 한다. 스크립트가 초점을 억지로 옮기는 .focus() 호출은 없다.
움직임을 줄인 화면에 남는 한 장
prefers-reduced-motion: reduce 를 켜 두면 시연 시간표와 애니메이션 루프가 돌지 않고, 240 스텝을 그리지 않은 채 한 번에 계산해 자리 잡은 화면 한 장만 남긴다. 01~06·08·09 는 진행 끝(1)의 화면이라 01 은 「선을 다 그었어요」, 03 은 「이정표 5/5 지남」, 09 는 「남은 거리 0.0 km · 도착했어요」 로 시작한다. 07 만 고정 구간 끝 0.75 에 멈추는데, 1 로 두면 모양이 무대 위로 빠져나가 빈 무대만 남기 때문이다. 이 상태에서도 상자를 굴리거나 키를 누르면 그 진행률의 자리 잡은 화면을 바로 다시 그리고, 설정은 change 리스너로 페이지를 연 채 바꿔도 반영된다. 기준은 prefers-reduced-motion 설명 의 미디어 조건을 그대로 따랐다.
스크롤 애니메이션 css 만으로 되는 범위와, 길 따라가기를 페이지에 들일 때 자주 묻는 질문
곡선을 따라 선만 그으면 되는데 CSS 로는 안 되나?
선을 긋는 것까지는 CSS 로 된다. 스크롤 스토리텔링 섹션 의 경로 지도 항목은 스크롤 타임라인과 SVG 경로로 선을 긋고, 그때 길이 재기는 SVG 쪽이 맡는다. 접선을 쫓아 머리를 트는 02, 넘친 뒤 자리 잡는 03 의 스프링, 마디를 되맞추는 06 의 꼬리처럼 지난 틱의 상태를 기억해야 하는 움직임은 진행률만으로 정해지지 않아 스크립트가 필요하다.
스크롤 애니메이션 효과를 페이지 전체 스크롤에 묶으려면 어디를 바꾸나?
진행률을 읽는 곳은 readScroll 한 줄이라, 상자의 scrollTop 대신 페이지 안에서 그 구간이 지나간 비율을 넣으면 나머지 계산은 그대로 쓸 수 있을 것으로 보인다. 다만 이때는 무대 위에 겹친 스크롤 상자와 overscroll-behavior: contain 을 걷어 내야 휠이 상자에 갇히지 않는다. 페이지 깊이를 숫자나 배지로만 보여 주면 될 때는 스크롤 인디케이터 9종 쪽이 더 가볍다.
휠을 빠르게 굴리면 계산이 밀리지 않나?
한 프레임에 계산하는 스텝은 최대 6번이고, 프레임 사이가 길어도 100ms 까지만 따라잡아 탭을 오래 비웠다 돌아와도 한꺼번에 수백 스텝을 몰아 돌리지 않는다. 길이 표는 장면이 시작할 때 한 번 만들고 매 틱에는 이분 탐색만 하며, 캔버스 화소 배율은 Math.min(window.devicePixelRatio || 1, 2) 로 2 에서 막아 두었다.









