05A Pen That Keeps the Same Speed Even Through the Curves
The gaps between dots are filled with cubic curves whose slopes come from the neighboring dots on either side, the curves are cut into short pieces to build a running length table, and progress is split by that length so the pen tip keeps the same speed through sharp bends.
Best forSignature drawing on a brand story page
PEN_SPEEDTICK_EVERYCURVE_STEPS

<main class="stage">
<div class="ld is-demo" id="ld">
<canvas class="ld__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A pen tip drawing a looping signature at the same speed through every bend, leaving evenly spaced dots at equal time steps."></canvas>
<button class="ld__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.ld {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.ld__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ld__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.ld__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.ld__pause:hover {
transform: translateY(-2px);
}
.ld__pause:active {
transform: translateY(1px);
}
.ld__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 어떤 행동 중인지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.ld__status {
position: absolute;
left: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.ld__pause,
.ld__status { font-size: 11px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 쉬는 자세 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.ld__pause { transition: none !important; }
.ld__pause:hover,
.ld__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.ld__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ld__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ld__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.ld__pause:hover {
transform: translateY(-2px);
}
.ld__pause:active {
transform: translateY(1px);
}
.ld__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.ld__status {
position: absolute;
left: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.ld__pause,
.ld__status {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld__pause {
transition: none !important;
}
.ld__pause:hover,
.ld__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("ld");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 점 배치·같은 떨림이 다시 나온다 (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;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 손 떨림에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(483);
for (let i = 0; i < 256; i += 1) {
NOISE_TABLE[i] = noiseRng() * 2 - 1;
}
function smoothNoise(x) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 도형 거리 — 타원 안이면 음수, 밖이면 양수(px 에 가까운 근사). 원본 그림을 코드로 그릴 때 쓴다
function ellipseDist(x, y, cx, cy, rx, ry) {
const dx = (x - cx) / rx;
const dy = (y - cy) / ry;
return (Math.sqrt(dx * dx + dy * dy) - 1) * Math.min(rx, ry);
}
// 선분까지의 거리(px)
function segmentDist(x, y, ax, ay, bx, by) {
const vx = bx - ax;
const vy = by - ay;
const k = clamp(((x - ax) * vx + (y - ay) * vy) / Math.max(1e-9, vx * vx + vy * vy), 0, 1);
const ex = x - (ax + vx * k);
const ey = y - (ay + vy * k);
return Math.sqrt(ex * ex + ey * ey);
}
// 거리 → 칠한 정도 0~1. 경계 1px 를 부드럽게 넘긴다
function fillOf(dist) { return clamp(0.5 - dist, 0, 1); }
// 두 점 사이 거리
function dist2(ax, ay, bx, by) {
const dx = ax - bx;
const dy = ay - by;
return Math.sqrt(dx * dx + dy * dy);
}
// 꺾은선 길이 — xs·ys 를 order 순서로 이었을 때(닫지 않음)
function pathLength(xs, ys, order, n) {
let sum = 0;
for (let i = 1; i < n; i += 1) {
sum += dist2(xs[order[i - 1]], ys[order[i - 1]], xs[order[i]], ys[order[i]]);
}
return sum;
}
// 파스텔 팔레트 — 미색 종이 바탕 위에 진한 잉크 선. 잉크·종이는 데모 토큰($subject-ink·$subject-cream)과 같은 값
const PAPER = [255, 247, 230];
const INK = [23, 20, 26];
const CORAL = [244, 166, 150];
const LAVENDER = [192, 176, 236];
const BUTTER = [246, 224, 150];
const SAGE = [164, 208, 172];
const PINK = [244, 182, 204];
const SKY = [164, 206, 236];
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 cyclePhase(t, drawTime, holdTime) {
// 0~1 은 그리는 중, 1~2 는 머무는 중, 2~3 은 지우는 중
if (t < drawTime) {
return t / drawTime;
}
if (t < drawTime + holdTime) {
return 1 + (t - drawTime) / holdTime;
}
return 2 + (t - drawTime - holdTime) / ERASE_TIME;
}
// 지우기 시간(초) — 모든 장면이 같은 빠르기로 종이색으로 돌아간다
const ERASE_TIME = 1;
// 곡선 공용 — 점 사이를 「이웃 네 점으로 정하는 3차 곡선」(조각마다 가운데 두 점을 지나고 바깥 두 점으로 기울기를 정한다)으로 메우고, 짧은 마디로 쪼개 누적 길이 표를 만든다
// 3차 조각 — 두 끝점 p1·p2 와 그 자리의 접선 m1·m2 로 정하는 곡선. t 는 0~1
function cubicPiece(p1, p2, m1, m2, t) {
const t2 = t * t;
const t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * p1 + (t3 - 2 * t2 + t) * m1 + (-2 * t3 + 3 * t2) * p2 + (t3 - t2) * m2;
}
// 이웃 접선 — 점 i 의 접선 = (다음 점 − 앞 점) ÷ 2. 이음매 양쪽 조각이 같은 접선을 쓰므로 꺾이지 않는다
function neighborTangents(px) {
const n = px.length;
const m = [];
for (let i = 0; i < n; i += 1) {
m.push((px[Math.min(n - 1, i + 1)] - px[Math.max(0, i - 1)]) / 2);
}
return m;
}
// 조각마다 CURVE_STEPS 마디 — 마디 끝마다 누적 길이를 적어 둔다. 길이 표의 칸 수 = 조각 수 × CURVE_STEPS + 1
const CURVE_STEPS = 16;
function buildCurve(px, py, mx, my) {
const xs = [px[0]];
const ys = [py[0]];
const lens = [0];
let total = 0;
for (let i = 0; i + 1 < px.length; i += 1) {
for (let k = 1; k <= CURVE_STEPS; k += 1) {
const t = k / CURVE_STEPS;
const x = cubicPiece(px[i], px[i + 1], mx[i], mx[i + 1], t);
const y = cubicPiece(py[i], py[i + 1], my[i], my[i + 1], t);
total += dist2(x, y, xs[xs.length - 1], ys[ys.length - 1]);
xs.push(x);
ys.push(y);
lens.push(total);
}
}
return { xs: Float32Array.from(xs), ys: Float32Array.from(ys), lens: Float32Array.from(lens), total: total, seams: px.length - 2 };
}
// 지나는 점들로 곡선 — 이웃 접선을 붙여 buildCurve
function curveThrough(px, py) { return buildCurve(px, py, neighborTangents(px), neighborTangents(py)); }
// 꺾은선 그대로 — 곡선으로 메우지 않고 점 사이를 곧게 잇되 같은 누적 길이 표를 만든다
function polylineCurve(px, py) {
const lens = [0];
let total = 0;
for (let i = 1; i < px.length; i += 1) {
total += dist2(px[i], py[i], px[i - 1], py[i - 1]);
lens.push(total);
}
return { xs: Float32Array.from(px), ys: Float32Array.from(py), lens: Float32Array.from(lens), total: total, seams: 0 };
}
// 길이 s 자리 — 누적 길이 표를 이진 탐색해 s 가 든 마디를 찾고, 그 마디 안에서 비율만큼. 소수 꼭짓점 번호를 돌려준다
function indexAtLength(c, s) {
if (s <= 0) {
return 0;
}
if (s >= c.total) {
return c.lens.length - 1;
}
let lo = 0;
let hi = c.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (c.lens[mid] < s) {
lo = mid;
}
else {
hi = mid;
}
}
return lo + (s - c.lens[lo]) / Math.max(1e-9, c.lens[hi] - c.lens[lo]);
}
// 소수 꼭짓점 번호 → 좌표
function pointAtIndex(c, f) {
const i = Math.min(Math.floor(f), c.xs.length - 2);
const k = f - i;
return [lerp(c.xs[i], c.xs[i + 1], k), lerp(c.ys[i], c.ys[i + 1], k)];
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
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);
}
// 미색 종이 — 위는 조금 밝고 아래는 조금 따뜻하게
function clearPaper() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(PAPER, 1));
g.addColorStop(1, rgba([250, 238, 216], 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 상태 줄 — 같은 글자면 DOM 을 건드리지 않는다
let statusText = '';
function setStatus(s) {
if (!statusEl || s === statusText) {
return;
}
statusText = s;
statusEl.textContent = s;
}
// 꺾은선 — xs·ys 의 0 번부터 count 개(소수 부분은 마지막 마디를 그만큼만)를 한 번에 긋는다
function strokeRun(xs, ys, count, width, color, alpha) {
const whole = Math.floor(count);
if (whole < 1) {
return;
}
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
const last = Math.min(whole, xs.length - 1);
for (let i = 1; i <= last; i += 1) {
ctx.lineTo(xs[i], ys[i]);
}
const f = count - whole;
if (f > 0 && whole + 1 < xs.length) {
ctx.lineTo(lerp(xs[whole], xs[whole + 1], f), lerp(ys[whole], ys[whole + 1], f));
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
// 꺾은선 한 토막 — from 번째 꼭짓점부터 to 번째까지(to 의 소수 부분은 마지막 마디를 그만큼만)
function strokeSpan(xs, ys, from, to, width, color, alpha) {
const end = Math.min(Math.floor(to), xs.length - 1);
if (end <= from) {
return;
}
ctx.beginPath();
ctx.moveTo(xs[from], ys[from]);
for (let i = from + 1; i <= end; i += 1) {
ctx.lineTo(xs[i], ys[i]);
}
const f = to - Math.floor(to);
if (f > 0 && end + 1 < xs.length) {
ctx.lineTo(lerp(xs[end], xs[end + 1], f), lerp(ys[end], ys[end + 1], f));
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
// 펜촉 — 지금 긋고 있는 자리. 잉크 점 둘레에 파스텔 고리
function drawNib(x, y, ring) {
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.fillStyle = rgba(ring, 0.75);
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 2.6, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
}
// 원본 그림 판 — 어둡기 함수(0~1)를 칸마다 칠한 화면 밖 캔버스. 펜이 그리기 전에 옅게 깔아 무엇을 따라 그리는지 보인다
function makeSourceLayer(dark, tint) {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lc = layer.getContext('2d');
if (!lc) {
return layer;
}
const img = lc.createImageData(W, H);
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
const d = dark(x + 0.5, y + 0.5);
const o = (y * W + x) * 4;
img.data[o] = Math.round(lerp(PAPER[0], tint[0], d));
img.data[o + 1] = Math.round(lerp(PAPER[1], tint[1], d));
img.data[o + 2] = Math.round(lerp(PAPER[2], tint[2], d));
img.data[o + 3] = 255;
}
}
lc.putImageData(img, 0, 0);
return layer;
}
// 지우는 중이면 그림 전체를 종이색으로 덮어 간다 — phase 2~3 에서 덮개 불투명도 0→1
function eraseVeil(phase) {
if (phase <= 2) {
return;
}
ctx.fillStyle = rgba(PAPER, smooth(phase - 2));
ctx.fillRect(0, 0, W, H);
}
// 05 같은 빠르기 펜 — 고리가 이어진 서명 점들을 이웃 네 점으로 정하는 곡선으로 잇고, 누적 길이 표로 「시간 × PEN_SPEED」 자리를 찾는다
const PEN_SPEED = 240;
// 같은 시간 간격 점 — TICK_EVERY 초마다 펜 자리에 점을 남긴다. 빠르기가 같으면 점 간격도 같다
const TICK_EVERY = 0.12;
const HOLD_TIME = 2;
// 서명 점 — 늘어진 사이클로이드를 θ 가 π/4 갈 때마다 찍어 고리 넷, 끝에 밑줄 한 번.
// 부풂 s = 1 − 0.25 cos(θ/4) 로 고리 크기가 달라지고, x = 78 + 13θ − 32·s·sin θ + (20 + 30·s·cos θ)·0.25, y = 130 − 30·s·cos θ
// — 마지막 항 (20 + 30·s·cos θ)·0.25 가 위로 갈수록(cos θ 가 클수록) 점을 오른쪽으로 밀어 기울인다
function signaturePoints() {
const px = [];
const py = [];
for (let k = 0; k <= 32; k += 1) {
const th = k * Math.PI / 4;
const swell = 1 - 0.25 * Math.cos(th / 4);
px.push(78 + 13 * th - 32 * swell * Math.sin(th) + (20 + 30 * swell * Math.cos(th)) * 0.25);
py.push(130 - 30 * swell * Math.cos(th));
}
const ex = px[px.length - 1];
px.push(ex + 22, ex + 4, ex - 80, ex - 190, ex - 300, ex - 360);
py.push(176, 214, 226, 220, 232, 218);
return [px, py];
}
const sig = signaturePoints();
const pen05 = curveThrough(sig[0], sig[1]);
const DRAW_TIME = pen05.total / PEN_SPEED;
const CYCLE = DRAW_TIME + HOLD_TIME + ERASE_TIME;
// 첫 화면이 비지 않게 고리 둘을 그린 자리(2.6초)에서 시작한다 — 순환이 돌면 처음부터 다시 그린다
const START_AT = 2.6;
const FINISH_T = DRAW_TIME + 0.3;
let penIndex = 0;
let ticks = 0;
function reset() { penIndex = 0; ticks = 0; }
function update(t, dt) {
penIndex = indexAtLength(pen05, Math.min(t, DRAW_TIME) * PEN_SPEED);
ticks = Math.floor(Math.min(t, DRAW_TIME) / TICK_EVERY);
}
// 비교 띠 — 길이 표 없이 조각마다 같은 시간을 줬다면 펜이 냈을 빠르기(조각 길이 ÷ 조각 시간)를 색으로.
// 하늘색 = 그 방식이면 느렸을 조각, 산호색 = 빨랐을 조각. 실제 펜은 길이 표 덕에 어디서나 PEN_SPEED
const pieceCount05 = sig[0].length - 1;
const meanPiece05 = pen05.total / pieceCount05;
function naiveSpeedColor(i) {
const len = pen05.lens[(i + 1) * CURVE_STEPS] - pen05.lens[i * CURVE_STEPS];
const k = clamp((len / meanPiece05 - 0.4) / 1.4, 0, 1);
return [lerp(SKY[0], CORAL[0], k), lerp(SKY[1], CORAL[1], k), lerp(SKY[2], CORAL[2], k)];
}
function draw(t) {
const phase = cyclePhase(t, DRAW_TIME, HOLD_TIME);
clearPaper();
// 같은 시간 간격 점 — 라벤더 동그라미. 굽은 곳에서도 간격이 같으면 펜 빠르기가 같은 것
ctx.beginPath();
for (let k = 1; k <= ticks; k += 1) {
const p = pointAtIndex(pen05, indexAtLength(pen05, k * TICK_EVERY * PEN_SPEED));
ctx.moveTo(p[0] + 5.5, p[1]);
ctx.arc(p[0], p[1], 5.5, 0, TAU);
}
ctx.fillStyle = rgba(LAVENDER, 0.9);
ctx.fill();
// 지나는 점(서명 점) — 곡선이 반드시 지나가는 자리를 옅게
ctx.beginPath();
for (let i = 0; i < sig[0].length; i += 1) {
ctx.moveTo(sig[0][i] + 2, sig[1][i]);
ctx.arc(sig[0][i], sig[1][i], 2, 0, TAU);
}
ctx.fillStyle = rgba(INK, 0.25);
ctx.fill();
for (let i = 0; i < pieceCount05; i += 1) {
const from = i * CURVE_STEPS;
if (from >= penIndex) {
break;
}
strokeSpan(pen05.xs, pen05.ys, from, Math.min(penIndex, from + CURVE_STEPS), 12, naiveSpeedColor(i), 0.6);
}
strokeRun(pen05.xs, pen05.ys, penIndex, 2.6, INK, 1);
if (phase < 1) {
const p = pointAtIndex(pen05, penIndex);
drawNib(p[0], p[1], CORAL);
}
eraseVeil(phase);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 앞당겨 돌리기 걸음(초) — 시작 자리(START_AT)나 완성 판(FINISH_T)까지 1/60초씩 계산만 하고 그리지 않는다
const SKIP_STEP = 1 / 60;
// 장면 시계(초) — 순환 하나 안에서의 자리. CYCLE 을 넘으면 0 으로 돌아가 처음부터 다시 그린다
let cycleT = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) {
live.setAttribute('aria-live', 'polite');
}
}
// 시계를 to 초까지 계산만 하고 넘긴다
function skipTo(to) {
while (cycleT + SKIP_STEP <= to) {
cycleT += SKIP_STEP;
update(cycleT, SKIP_STEP);
}
}
function advance(dt) {
cycleT += dt;
if (cycleT >= CYCLE) {
cycleT = 0;
reset();
}
update(cycleT, dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw(cycleT);
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 다 그려진 한 장(FINISH_T)만 그린다
function showFinished() {
stopLoop();
cycleT = 0;
reset();
skipTo(FINISH_T);
draw(cycleT);
}
function play() {
cycleT = 0;
reset();
skipTo(START_AT);
draw(cycleT);
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 이어 그린다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 완성 판으로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showFinished();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw(cycleT);
}, { signal: off.signal });
fitCanvas();
if (still) {
showFinished();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SmoothPenConstantSpeed - vanilla 판(05-oneline-smooth-pen-constant-speed) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SmoothPenConstantSpeed.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SmoothPenConstantSpeed() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type Pt = [number, number];
type RGB = [number, number, number];
// 점 묶음 — 좌표 두 배열과 점 수
type Dots = { xs: Float32Array; ys: Float32Array; n: number };
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 점 배치·같은 떨림이 다시 나온다 (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;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 손 떨림에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(483);
for (let i = 0; i < 256; i += 1) { NOISE_TABLE[i] = noiseRng() * 2 - 1; }
function smoothNoise(x: number): number {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 도형 거리 — 타원 안이면 음수, 밖이면 양수(px 에 가까운 근사). 원본 그림을 코드로 그릴 때 쓴다
function ellipseDist(x: number, y: number, cx: number, cy: number, rx: number, ry: number): number {
const dx = (x - cx) / rx;
const dy = (y - cy) / ry;
return (Math.sqrt(dx * dx + dy * dy) - 1) * Math.min(rx, ry);
}
// 선분까지의 거리(px)
function segmentDist(x: number, y: number, ax: number, ay: number, bx: number, by: number): number {
const vx = bx - ax;
const vy = by - ay;
const k = clamp(((x - ax) * vx + (y - ay) * vy) / Math.max(1e-9, vx * vx + vy * vy), 0, 1);
const ex = x - (ax + vx * k);
const ey = y - (ay + vy * k);
return Math.sqrt(ex * ex + ey * ey);
}
// 거리 → 칠한 정도 0~1. 경계 1px 를 부드럽게 넘긴다
function fillOf(dist: number): number { return clamp(0.5 - dist, 0, 1); }
// 두 점 사이 거리
function dist2(ax: number, ay: number, bx: number, by: number): number {
const dx = ax - bx;
const dy = ay - by;
return Math.sqrt(dx * dx + dy * dy);
}
// 꺾은선 길이 — xs·ys 를 order 순서로 이었을 때(닫지 않음)
function pathLength(xs: Float32Array, ys: Float32Array, order: Int32Array, n: number): number {
let sum = 0;
for (let i = 1; i < n; i += 1) { sum += dist2(xs[order[i - 1]], ys[order[i - 1]], xs[order[i]], ys[order[i]]); }
return sum;
}
// 파스텔 팔레트 — 미색 종이 바탕 위에 진한 잉크 선. 잉크·종이는 데모 토큰($subject-ink·$subject-cream)과 같은 값
const PAPER: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const CORAL: RGB = [244, 166, 150];
const LAVENDER: RGB = [192, 176, 236];
const BUTTER: RGB = [246, 224, 150];
const SAGE: RGB = [164, 208, 172];
const PINK: RGB = [244, 182, 204];
const SKY: RGB = [164, 206, 236];
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 cyclePhase(t: number, drawTime: number, holdTime: number): number {
// 0~1 은 그리는 중, 1~2 는 머무는 중, 2~3 은 지우는 중
if (t < drawTime) { return t / drawTime; }
if (t < drawTime + holdTime) { return 1 + (t - drawTime) / holdTime; }
return 2 + (t - drawTime - holdTime) / ERASE_TIME;
}
// 지우기 시간(초) — 모든 장면이 같은 빠르기로 종이색으로 돌아간다
const ERASE_TIME = 1;
// 곡선 공용 — 점 사이를 「이웃 네 점으로 정하는 3차 곡선」(조각마다 가운데 두 점을 지나고 바깥 두 점으로 기울기를 정한다)으로 메우고, 짧은 마디로 쪼개 누적 길이 표를 만든다
// 3차 조각 — 두 끝점 p1·p2 와 그 자리의 접선 m1·m2 로 정하는 곡선. t 는 0~1
function cubicPiece(p1: number, p2: number, m1: number, m2: number, t: number): number {
const t2 = t * t;
const t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * p1 + (t3 - 2 * t2 + t) * m1 + (-2 * t3 + 3 * t2) * p2 + (t3 - t2) * m2;
}
// 이웃 접선 — 점 i 의 접선 = (다음 점 − 앞 점) ÷ 2. 이음매 양쪽 조각이 같은 접선을 쓰므로 꺾이지 않는다
function neighborTangents(px: number[]): number[] {
const n = px.length;
const m: number[] = [];
for (let i = 0; i < n; i += 1) { m.push((px[Math.min(n - 1, i + 1)] - px[Math.max(0, i - 1)]) / 2); }
return m;
}
type Curve = { xs: Float32Array; ys: Float32Array; lens: Float32Array; total: number; seams: number };
// 조각마다 CURVE_STEPS 마디 — 마디 끝마다 누적 길이를 적어 둔다. 길이 표의 칸 수 = 조각 수 × CURVE_STEPS + 1
const CURVE_STEPS = 16;
function buildCurve(px: number[], py: number[], mx: number[], my: number[]): Curve {
const xs: number[] = [px[0]];
const ys: number[] = [py[0]];
const lens: number[] = [0];
let total = 0;
for (let i = 0; i + 1 < px.length; i += 1) {
for (let k = 1; k <= CURVE_STEPS; k += 1) {
const t = k / CURVE_STEPS;
const x = cubicPiece(px[i], px[i + 1], mx[i], mx[i + 1], t);
const y = cubicPiece(py[i], py[i + 1], my[i], my[i + 1], t);
total += dist2(x, y, xs[xs.length - 1], ys[ys.length - 1]);
xs.push(x);
ys.push(y);
lens.push(total);
}
}
return { xs: Float32Array.from(xs), ys: Float32Array.from(ys), lens: Float32Array.from(lens), total: total, seams: px.length - 2 };
}
// 지나는 점들로 곡선 — 이웃 접선을 붙여 buildCurve
function curveThrough(px: number[], py: number[]): Curve { return buildCurve(px, py, neighborTangents(px), neighborTangents(py)); }
// 꺾은선 그대로 — 곡선으로 메우지 않고 점 사이를 곧게 잇되 같은 누적 길이 표를 만든다
function polylineCurve(px: number[], py: number[]): Curve {
const lens: number[] = [0];
let total = 0;
for (let i = 1; i < px.length; i += 1) { total += dist2(px[i], py[i], px[i - 1], py[i - 1]); lens.push(total); }
return { xs: Float32Array.from(px), ys: Float32Array.from(py), lens: Float32Array.from(lens), total: total, seams: 0 };
}
// 길이 s 자리 — 누적 길이 표를 이진 탐색해 s 가 든 마디를 찾고, 그 마디 안에서 비율만큼. 소수 꼭짓점 번호를 돌려준다
function indexAtLength(c: Curve, s: number): number {
if (s <= 0) { return 0; }
if (s >= c.total) { return c.lens.length - 1; }
let lo = 0;
let hi = c.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (c.lens[mid] < s) { lo = mid; } else { hi = mid; }
}
return lo + (s - c.lens[lo]) / Math.max(1e-9, c.lens[hi] - c.lens[lo]);
}
// 소수 꼭짓점 번호 → 좌표
function pointAtIndex(c: Curve, f: number): Pt {
const i = Math.min(Math.floor(f), c.xs.length - 2);
const k = f - i;
return [lerp(c.xs[i], c.xs[i + 1], k), lerp(c.ys[i], c.ys[i + 1], k)];
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
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);
}
// 미색 종이 — 위는 조금 밝고 아래는 조금 따뜻하게
function clearPaper(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(PAPER, 1));
g.addColorStop(1, rgba([250, 238, 216], 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 상태 줄 — 같은 글자면 DOM 을 건드리지 않는다
let statusText = '';
function setStatus(s: string): void {
if (!statusEl || s === statusText) { return; }
statusText = s;
statusEl.textContent = s;
}
// 꺾은선 — xs·ys 의 0 번부터 count 개(소수 부분은 마지막 마디를 그만큼만)를 한 번에 긋는다
function strokeRun(xs: Float32Array, ys: Float32Array, count: number, width: number, color: RGB, alpha: number): void {
const whole = Math.floor(count);
if (whole < 1) { return; }
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
const last = Math.min(whole, xs.length - 1);
for (let i = 1; i <= last; i += 1) { ctx.lineTo(xs[i], ys[i]); }
const f = count - whole;
if (f > 0 && whole + 1 < xs.length) { ctx.lineTo(lerp(xs[whole], xs[whole + 1], f), lerp(ys[whole], ys[whole + 1], f)); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
// 꺾은선 한 토막 — from 번째 꼭짓점부터 to 번째까지(to 의 소수 부분은 마지막 마디를 그만큼만)
function strokeSpan(xs: Float32Array, ys: Float32Array, from: number, to: number, width: number, color: RGB, alpha: number): void {
const end = Math.min(Math.floor(to), xs.length - 1);
if (end <= from) { return; }
ctx.beginPath();
ctx.moveTo(xs[from], ys[from]);
for (let i = from + 1; i <= end; i += 1) { ctx.lineTo(xs[i], ys[i]); }
const f = to - Math.floor(to);
if (f > 0 && end + 1 < xs.length) { ctx.lineTo(lerp(xs[end], xs[end + 1], f), lerp(ys[end], ys[end + 1], f)); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
// 펜촉 — 지금 긋고 있는 자리. 잉크 점 둘레에 파스텔 고리
function drawNib(x: number, y: number, ring: RGB): void {
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.fillStyle = rgba(ring, 0.75);
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 2.6, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
}
// 원본 그림 판 — 어둡기 함수(0~1)를 칸마다 칠한 화면 밖 캔버스. 펜이 그리기 전에 옅게 깔아 무엇을 따라 그리는지 보인다
function makeSourceLayer(dark: (x: number, y: number) => number, tint: RGB): HTMLCanvasElement {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lc = layer.getContext('2d');
if (!lc) { return layer; }
const img = lc.createImageData(W, H);
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
const d = dark(x + 0.5, y + 0.5);
const o = (y * W + x) * 4;
img.data[o] = Math.round(lerp(PAPER[0], tint[0], d));
img.data[o + 1] = Math.round(lerp(PAPER[1], tint[1], d));
img.data[o + 2] = Math.round(lerp(PAPER[2], tint[2], d));
img.data[o + 3] = 255;
}
}
lc.putImageData(img, 0, 0);
return layer;
}
// 지우는 중이면 그림 전체를 종이색으로 덮어 간다 — phase 2~3 에서 덮개 불투명도 0→1
function eraseVeil(phase: number): void {
if (phase <= 2) { return; }
ctx.fillStyle = rgba(PAPER, smooth(phase - 2));
ctx.fillRect(0, 0, W, H);
}
// 05 같은 빠르기 펜 — 고리가 이어진 서명 점들을 이웃 네 점으로 정하는 곡선으로 잇고, 누적 길이 표로 「시간 × PEN_SPEED」 자리를 찾는다
const PEN_SPEED = 240;
// 같은 시간 간격 점 — TICK_EVERY 초마다 펜 자리에 점을 남긴다. 빠르기가 같으면 점 간격도 같다
const TICK_EVERY = 0.12;
const HOLD_TIME = 2;
// 서명 점 — 늘어진 사이클로이드를 θ 가 π/4 갈 때마다 찍어 고리 넷, 끝에 밑줄 한 번.
// 부풂 s = 1 − 0.25 cos(θ/4) 로 고리 크기가 달라지고, x = 78 + 13θ − 32·s·sin θ + (20 + 30·s·cos θ)·0.25, y = 130 − 30·s·cos θ
// — 마지막 항 (20 + 30·s·cos θ)·0.25 가 위로 갈수록(cos θ 가 클수록) 점을 오른쪽으로 밀어 기울인다
function signaturePoints(): number[][] {
const px: number[] = [];
const py: number[] = [];
for (let k = 0; k <= 32; k += 1) {
const th = k * Math.PI / 4;
const swell = 1 - 0.25 * Math.cos(th / 4);
px.push(78 + 13 * th - 32 * swell * Math.sin(th) + (20 + 30 * swell * Math.cos(th)) * 0.25);
py.push(130 - 30 * swell * Math.cos(th));
}
const ex = px[px.length - 1];
px.push(ex + 22, ex + 4, ex - 80, ex - 190, ex - 300, ex - 360);
py.push(176, 214, 226, 220, 232, 218);
return [px, py];
}
const sig = signaturePoints();
const pen05: Curve = curveThrough(sig[0], sig[1]);
const DRAW_TIME = pen05.total / PEN_SPEED;
const CYCLE = DRAW_TIME + HOLD_TIME + ERASE_TIME;
// 첫 화면이 비지 않게 고리 둘을 그린 자리(2.6초)에서 시작한다 — 순환이 돌면 처음부터 다시 그린다
const START_AT = 2.6;
const FINISH_T = DRAW_TIME + 0.3;
let penIndex = 0;
let ticks = 0;
function reset(): void { penIndex = 0; ticks = 0; }
function update(t: number, dt: number): void {
penIndex = indexAtLength(pen05, Math.min(t, DRAW_TIME) * PEN_SPEED);
ticks = Math.floor(Math.min(t, DRAW_TIME) / TICK_EVERY);
}
// 비교 띠 — 길이 표 없이 조각마다 같은 시간을 줬다면 펜이 냈을 빠르기(조각 길이 ÷ 조각 시간)를 색으로.
// 하늘색 = 그 방식이면 느렸을 조각, 산호색 = 빨랐을 조각. 실제 펜은 길이 표 덕에 어디서나 PEN_SPEED
const pieceCount05 = sig[0].length - 1;
const meanPiece05 = pen05.total / pieceCount05;
function naiveSpeedColor(i: number): RGB {
const len = pen05.lens[(i + 1) * CURVE_STEPS] - pen05.lens[i * CURVE_STEPS];
const k = clamp((len / meanPiece05 - 0.4) / 1.4, 0, 1);
return [lerp(SKY[0], CORAL[0], k), lerp(SKY[1], CORAL[1], k), lerp(SKY[2], CORAL[2], k)];
}
function draw(t: number): void {
const phase = cyclePhase(t, DRAW_TIME, HOLD_TIME);
clearPaper();
// 같은 시간 간격 점 — 라벤더 동그라미. 굽은 곳에서도 간격이 같으면 펜 빠르기가 같은 것
ctx.beginPath();
for (let k = 1; k <= ticks; k += 1) {
const p = pointAtIndex(pen05, indexAtLength(pen05, k * TICK_EVERY * PEN_SPEED));
ctx.moveTo(p[0] + 5.5, p[1]);
ctx.arc(p[0], p[1], 5.5, 0, TAU);
}
ctx.fillStyle = rgba(LAVENDER, 0.9);
ctx.fill();
// 지나는 점(서명 점) — 곡선이 반드시 지나가는 자리를 옅게
ctx.beginPath();
for (let i = 0; i < sig[0].length; i += 1) {
ctx.moveTo(sig[0][i] + 2, sig[1][i]);
ctx.arc(sig[0][i], sig[1][i], 2, 0, TAU);
}
ctx.fillStyle = rgba(INK, 0.25);
ctx.fill();
for (let i = 0; i < pieceCount05; i += 1) {
const from = i * CURVE_STEPS;
if (from >= penIndex) { break; }
strokeSpan(pen05.xs, pen05.ys, from, Math.min(penIndex, from + CURVE_STEPS), 12, naiveSpeedColor(i), 0.6);
}
strokeRun(pen05.xs, pen05.ys, penIndex, 2.6, INK, 1);
if (phase < 1) {
const p = pointAtIndex(pen05, penIndex);
drawNib(p[0], p[1], CORAL);
}
eraseVeil(phase);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 앞당겨 돌리기 걸음(초) — 시작 자리(START_AT)나 완성 판(FINISH_T)까지 1/60초씩 계산만 하고 그리지 않는다
const SKIP_STEP = 1 / 60;
// 장면 시계(초) — 순환 하나 안에서의 자리. CYCLE 을 넘으면 0 으로 돌아가 처음부터 다시 그린다
let cycleT = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) { live.setAttribute('aria-live', 'polite'); }
}
// 시계를 to 초까지 계산만 하고 넘긴다
function skipTo(to: number): void {
while (cycleT + SKIP_STEP <= to) {
cycleT += SKIP_STEP;
update(cycleT, SKIP_STEP);
}
}
function advance(dt: number): void {
cycleT += dt;
if (cycleT >= CYCLE) {
cycleT = 0;
reset();
}
update(cycleT, dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw(cycleT);
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 다 그려진 한 장(FINISH_T)만 그린다
function showFinished(): void {
stopLoop();
cycleT = 0;
reset();
skipTo(FINISH_T);
draw(cycleT);
}
function play(): void {
cycleT = 0;
reset();
skipTo(START_AT);
draw(cycleT);
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 이어 그린다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 완성 판으로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showFinished(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw(cycleT);
}, { signal: off.signal });
fitCanvas();
if (still) { showFinished(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("ld") + " is-demo"} ref={rootRef}>
<canvas className={cx("ld__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="고리가 이어진 서명 한 줄을 펜촉이 굽은 곳에서도 같은 빠르기로 그려 나가고 같은 시간 간격의 점이 고르게 찍히는 그림."></canvas>
<button className={cx("ld__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 A Pen That Keeps the Same Speed Even Through the Curves — Measured area 4.36% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · One Line Drawing Generator: 9 Canvas Scenes
The post this demo lives in
One Line Drawing Generator: 9 Canvas ScenesIn a one line drawing generator, join each dot to its nearest neighbor and the line keeps crossing itself, so 04 flips c See all demos in this post
If you like this effect - similar demos
Creative UI07 Cells That Even Out as the Iteration-count Value GrowsVoronoi Diagram Generator — 9 Patterns
Creative UI05 Scene That Keeps Drawing While the Trail Slowly FadesSpirograph Generator: 9 Gear-Ratio Curve Patterns
Creative UI06 Polar Layout That Fans the Same Tree OutCSS Org Chart: 9 Tidy Tree Layout Demos