09地図の道を走りながら残りの距離を数える配送追跡の道のり
線を引く・接線を追う・道しるべのばねを一本の道にまとめ、箱が地図上の経路を走りながら曲がる速さの分だけ曲がりの内側へ傾き、累積の長さの割合で残りの距離の数字を数え、停留所を通るたびにピンが立って状態の文字が変わる。
使いどころ注文の配送状況画面
ROUTE_TOTAL_KMSTOP_COUNTBOX_TILT_MAX

<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 オリジナル · SCSS と React は zip に同梱 · スクロール 連動 アニメーション 9種 — 線・道・しっぽ・らせん
このデモが属する記事
スクロール 連動 アニメーション 9種 — 線・道・しっぽ・らせんスクロール 連動 アニメーションで線や物を曲がった道に沿わせると、曲がり角でだけ急に速くなる癖が出ます。スクロールの進み具合をそのまま曲線の変数に入れると、01番のペン先の速さは曲がりごとに最大6.2倍まで開きます。曲線の長さを200区間で この記事のデモをすべて見る

