04베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다
핀 몇 개를 컨트롤 점으로 정해 끌면 실 배치가 실시간으로 다시 계산되는데, 그림 도구의 베지어 핸들과 같은 조작을 실 감기로 옮긴 인터랙션입니다.
쓰임창작 도구, 디자인 놀이
lineTobeginPathpointermove

<main class="stage">
<div class="sd is-demo" id="sdRoot" role="application" aria-label="베지어 실험판" tabindex="0">
<canvas class="sd__cv" id="sdCv"></canvas>
<p class="sd__hint">
<span class="sd__title">베지어 감기</span>
<span class="sd__desc">컨트롤 핀을 끌면 실 배치가 실시간으로 다시 감긴다</span>
</p>
<div class="sd__bar">
<span class="sd__meta">컨트롤 핀 3개 · 실 곡선 1·5초마다 재계산</span>
<span class="sd__meta sd__meta--dim">pointermove · lineTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 베지어 감기(04) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(핀 글라이드 + 실 재감기)가 만든다.
// 무대 종이 — 밝은 판 위 먼지남색 실로 베지어 부채꼴을 띄운다. 캔버스가 판 전체다.
$sd-stage: #f1ecdf;
@include stage($sd-stage);
$sd-panel: #f9f6ee; // 감기판 바닥 — 종이보다 한 단계 밝은 크림
$sd-text: #3a3448; // 라벨 글자 — 진먹보라 잉크
$sd-thread: #6f7fae; // 실 — 먼지남색 파스텔
$sd-pin: #35304a; // 고정핀 — 진먹보라
$sd-hot: #d98fa6; // 컨트롤 핀 광택 고리 — 더스티 로즈
$sd-guide: #b9b2a0; // 사슬 가이드 점선 — 웜 그레이
$duration: $dur-loop;
$easing: $ease-out;
$color: $sd-thread;
.sd {
--sd-panel: #{$sd-panel};
--sd-thread: #{$sd-thread};
--sd-pin: #{$sd-pin};
--sd-hot: #{$sd-hot};
--sd-guide: #{$sd-guide};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sd__cv {
position: absolute; inset: 0; display: block;
width: 100%; height: 100%;
touch-action: none; // 컨트롤 핀 끌기가 페이지 스크롤과 겹치지 않게
cursor: crosshair;
}
.sd__hint {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 0; margin: 0;
padding: $sp-2 $sp-3 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $sd-text;
text-shadow: 0 1px 4px rgba($sd-panel, .9);
}
.sd__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sd-text, .78);
text-shadow: 0 1px 4px rgba($sd-panel, .9);
}
.sd__bar {
position: relative; z-index: 1;
margin-top: auto;
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-3 $sp-2;
}
.sd__meta {
font-size: 10px; font-weight: 500; color: rgba($sd-text, .78);
background: rgba($sd-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sd:focus-visible { outline: 2px solid $sd-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 부채꼴 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sd.is-still,
.sd.is-still .sd__cv { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f1ecdf;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sd {
--sd-panel: #f9f6ee;
--sd-thread: #6f7fae;
--sd-pin: #35304a;
--sd-hot: #d98fa6;
--sd-guide: #b9b2a0;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sd__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
}
.sd__hint {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 8px 12px 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #3a3448;
text-shadow: 0 1px 4px rgba(249, 246, 238, 0.9);
}
.sd__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(58, 52, 72, 0.78);
text-shadow: 0 1px 4px rgba(249, 246, 238, 0.9);
}
.sd__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 52, 72, 0.78);
background: rgba(249, 246, 238, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.sd:focus-visible {
outline: 2px solid #6f7fae;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sd.is-still,
.sd.is-still .sd__cv {
cursor: default !important;
}
}// 스트링아트 베지어 감기(04) — 컨트롤 핀이 실 부채꼴의 뼈대가 되는 드 카스텔료 구성.
// 양끝 고정핀 A·B 와 그 사이 컨트롤 핀 3개(C1·C2·C3)를 사슬로 잇고,
// 사슬의 같은 비율 점끼리 실을 건다 — 실의 포락선이 베지어 곡선을 따라간다.
// 컨트롤 핀을 끌면(pointermove) 그 자리에서 전체 실배치가 다시 감기고,
// 놔두면 1.5초마다 핀이 스스로 다음 자리로 옮겨가며 재계산한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NCTRL = 3; // 컨트롤 핀 개수
var RECOMPUTE = 1.5; // 실 배치 재계산 주기(초)
var FAN = 128; // 바깥 부채꼴 실 수 (A→C1 · C3→B 사이)
var FAN2 = 96; // 안쪽 부채꼴 실 수 (C1→C2 · C2→C3 사이)
var FAN3 = 84; // 옆 부채꼴 실 수 (사슬 앞쪽·뒤쪽 각 1개)
// ────────────────────────────────────────────────────
var root = document.getElementById('sdRoot');
var cvEl = document.getElementById('sdCv');
if (!root || !cvEl) return;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var COL_THRD = tokRGB('--sd-thread');
var COL_PIN = tokRGB('--sd-pin');
var COL_HOT = tokRGB('--sd-hot');
var COL_GUIDE = tokRGB('--sd-guide');
var COL_PANEL = tokRGB('--sd-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
// 양끝 고정핀(비율 좌표) — 컨트롤 핀만 움직인다
var A = [0.05, 0.50], B = [0.95, 0.50];
// 컨트롤 핀 목표 자리 후보 — 1.5초마다 다음 후보로 글라이드(무대 전체 폭을 쓴다)
var PRESETS = [
[[0.30, 0.22], [0.50, 0.82], [0.70, 0.24]],
[[0.26, 0.78], [0.54, 0.16], [0.76, 0.70]],
[[0.36, 0.14], [0.66, 0.86], [0.80, 0.26]],
[[0.22, 0.46], [0.50, 0.90], [0.72, 0.42]],
[[0.38, 0.84], [0.60, 0.14], [0.82, 0.74]],
[[0.28, 0.28], [0.70, 0.40], [0.78, 0.82]]
];
var cur = PRESETS[0].map(function (p) { return [p[0], p[1]]; });
var tgt = PRESETS[0].map(function (p) { return [p[0], p[1]]; });
function px(p) { return [p[0] * W, p[1] * H]; }
function lerp(p, q, u) { return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]; }
function fit() {
var r = root.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = Math.round(W * dpr); cvEl.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(t, progress) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var C1 = cur[0], C2 = cur[1], C3 = cur[2];
// 사슬 가이드 — 실이 어느 뼈대에 걸리는지 보여주는 점선
ctx.strokeStyle = 'rgba(' + COL_GUIDE.join(',') + ',0.55)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(px(A)[0], px(A)[1]);
ctx.lineTo(px(C1)[0], px(C1)[1]);
ctx.lineTo(px(C2)[0], px(C2)[1]);
ctx.lineTo(px(C3)[0], px(C3)[1]);
ctx.lineTo(px(B)[0], px(B)[1]);
ctx.stroke();
ctx.setLineDash([]);
// 실 — 두 부채꼴. 방금 감은 올일수록 밝고 굵다(recency)
ctx.lineCap = 'round';
ctx.shadowBlur = 6;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.35)';
var i, u, recency, a, b, alpha;
for (i = 0; i < FAN; i++) {
u = i / (FAN - 1);
if (u > progress) continue;
a = px(lerp(A, C1, u)); b = px(lerp(C3, B, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.44 + 0.5 * Math.pow(recency, 1.1);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.4 + recency * 1.8;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (i = 0; i < FAN2; i++) {
u = i / (FAN2 - 1);
if (u > progress) continue;
a = px(lerp(C1, C2, u)); b = px(lerp(C2, C3, u));
recency = Math.max(0, 1 - (progress - u));
alpha = (0.38 + 0.5 * Math.pow(recency, 1.1)) * 0.9;
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.9, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.1 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
// 옆 부채꼴 2개 — 사슬 앞쪽(A·C1·C2)과 뒤쪽(C2·C3·B)을 각자 부채로 펼친다
for (i = 0; i < FAN3; i++) {
u = i / (FAN3 - 1);
if (u > progress) continue;
a = px(lerp(A, C1, u)); b = px(lerp(C1, C2, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.36 + 0.48 * Math.pow(recency, 1.1);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.9, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.2 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (i = 0; i < FAN3; i++) {
u = i / (FAN3 - 1);
if (u > progress) continue;
a = px(lerp(C2, C3, u)); b = px(lerp(C3, B, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.36 + 0.48 * Math.pow(recency, 1.1);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.9, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.2 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 고정핀 — 마름모
var anchors = [px(A), px(B)];
for (i = 0; i < 2; i++) {
var an = anchors[i];
ctx.fillStyle = 'rgb(' + COL_PIN.join(',') + ')';
ctx.beginPath();
ctx.moveTo(an[0], an[1] - 5); ctx.lineTo(an[0] + 5, an[1]);
ctx.lineTo(an[0], an[1] + 5); ctx.lineTo(an[0] - 5, an[1]);
ctx.closePath(); ctx.fill();
}
// 컨트롤 핀 — 실색 채운 원 + 광택 고리(끌고 있는 핀은 더 밝게 맥동)
var dragBoost = 0.5 + 0.5 * Math.sin(t * 6);
for (i = 0; i < NCTRL; i++) {
var c = px(cur[i]);
ctx.strokeStyle = 'rgba(' + COL_HOT.join(',') + ',' + (0.55 + (drag === i ? 0.45 * dragBoost : 0)).toFixed(3) + ')';
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(c[0], c[1], drag === i ? 10 : 8, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgb(' + COL_THRD.join(',') + ')';
ctx.beginPath(); ctx.arc(c[0], c[1], 4.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgb(' + COL_PIN.join(',') + ')';
ctx.beginPath(); ctx.arc(c[0], c[1], 1.8, 0, Math.PI * 2); ctx.fill();
}
}
// ── 포인터 — 컨트롤 핀 끌기(pointermove 로 실시간 재감기) ──
var drag = -1;
function evPos(e) {
var r = cvEl.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function hit(x, y) {
for (var k = 0; k < NCTRL; k++) {
var c = px(cur[k]);
var dx = x - c[0], dy = y - c[1];
if (dx * dx + dy * dy < 18 * 18) return k;
}
return -1;
}
cvEl.addEventListener('pointerdown', function (e) {
var p = evPos(e);
drag = hit(p[0], p[1]);
if (drag >= 0) {
try { cvEl.setPointerCapture(e.pointerId); } catch (err) { /* 캡처 실패해도 드래그는 유지 */ }
e.preventDefault();
}
});
cvEl.addEventListener('pointermove', function (e) {
if (drag < 0) return;
var p = evPos(e);
var rx = Math.max(0.06, Math.min(0.94, p[0] / W));
var ry = Math.max(0.10, Math.min(0.90, p[1] / H));
cur[drag] = [rx, ry]; tgt[drag] = [rx, ry];
e.preventDefault();
});
function drop() { drag = -1; }
cvEl.addEventListener('pointerup', drop);
cvEl.addEventListener('pointercancel', drop);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
// 1.5초마다 다음 자리로 목표 갱신 — 컨트롤 핀이 스스로 움직여 재계산을 보여준다
var pi = Math.floor(t / RECOMPUTE) % PRESETS.length;
for (var k = 0; k < NCTRL; k++) {
if (drag !== k) tgt[k] = PRESETS[pi][k];
cur[k][0] += (tgt[k][0] - cur[k][0]) * 0.09;
cur[k][1] += (tgt[k][1] - cur[k][1]) * 0.09;
}
// 주기 안 앞 85% 에 걸쳐 다시 감고, 나머지 구간은 핀 글라이드가 계속 움직인다
var progress = Math.min(1, (t % RECOMPUTE) / RECOMPUTE / 0.85);
draw(t, progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0, 1);
});
fit();
if (reduce) {
draw(0, 1); // reduce — 다 감긴 부채꼴 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringBezierPinsControl — vanilla 판(run/326/demo/04-string-bezier-pins-control/index.html) 의 React 포트.
// 컨트롤 핀이 실 부채꼴의 뼈대가 되는 드 카스텔료 구성. 양끝 고정핀 A·B 와 그 사이
// 컨트롤 핀 3개(C1·C2·C3)를 사슬로 잇고, 사슬의 같은 비율 점끼리 실을 건다 —
// 실의 포락선이 베지어 곡선을 따라간다. 컨트롤 핀을 끌면(pointermove) 그 자리에서
// 전체 실배치가 다시 감기고, 놔두면 1.5초마다 핀이 스스로 다음 자리로 옮겨가며 재계산한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringBezierPinsControl.module.scss";
export type StringBezierPinsControlProps = {
duration?: string;
easing?: string;
color?: string;
recompute?: number; // 실 배치 재계산 주기(초)
fan?: number; // 바깥 부채꼴 실 수 (A→C1 · C3→B 사이)
fan2?: number; // 안쪽 부채꼴 실 수 (C1→C2 · C2→C3 사이)
fan3?: number; // 옆 부채꼴 실 수 (사슬 앞쪽·뒤쪽 각 1개)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NCTRL = 3; // 컨트롤 핀 개수
const RECOMPUTE = 1.5; // 실 배치 재계산 주기(초)
const FAN = 128; // 바깥 부채꼴 실 수
const FAN2 = 96; // 안쪽 부채꼴 실 수
const FAN3 = 84; // 옆 부채꼴 실 수
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
type UV = [number, number]; // 비율 좌표
const BOARD_LABEL = "베지어 실험판";
const META_LEFT = "컨트롤 핀 3개 · 실 곡선 1·5초마다 재계산";
const META_RIGHT = "pointermove · lineTo";
const HINT_TITLE = "베지어 감기";
const HINT_DESC = "컨트롤 핀을 끌면 실 배치가 실시간으로 다시 감긴다";
// 양끝 고정핀(비율 좌표) — 컨트롤 핀만 움직인다
const A: UV = [0.05, 0.50], B: UV = [0.95, 0.50];
// 컨트롤 핀 목표 자리 후보 — 재계산 주기마다 다음 후보로 글라이드(무대 전체 폭을 쓴다)
const PRESETS: UV[][] = [
[[0.30, 0.22], [0.50, 0.82], [0.70, 0.24]],
[[0.26, 0.78], [0.54, 0.16], [0.76, 0.70]],
[[0.36, 0.14], [0.66, 0.86], [0.80, 0.26]],
[[0.22, 0.46], [0.50, 0.90], [0.72, 0.42]],
[[0.38, 0.84], [0.60, 0.14], [0.82, 0.74]],
[[0.28, 0.28], [0.70, 0.40], [0.78, 0.82]]
];
export default function StringBezierPinsControl({
duration = "2s",
easing = "ease-out",
color = "#6f7fae",
recompute = RECOMPUTE,
fan = FAN,
fan2 = FAN2,
fan3 = FAN3
}: StringBezierPinsControlProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; pin: RGB; hot: RGB; guide: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const curRef = useRef<UV[]>(PRESETS[0].map((p): UV => [p[0], p[1]]));
const tgtRef = useRef<UV[]>(PRESETS[0].map((p): UV => [p[0], p[1]]));
const dragRef = useRef(-1) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function px(p: UV): XY {
return [p[0] * wRef.current, p[1] * hRef.current];
}
function lerp(p: UV, q: UV, u: number): UV {
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u];
}
function fit(): void {
const root = rootRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!root || !cv || !ctx) return;
const r = root.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = Math.round(wRef.current * dpr);
cv.height = Math.round(hRef.current * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(t: number, progress: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
const cur = curRef.current;
const drag = dragRef.current;
if (!ctx || !col || !cur) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const C1 = cur[0], C2 = cur[1], C3 = cur[2];
// 사슬 가이드 — 실이 어느 뼈대에 걸리는지 보여주는 점선
ctx.strokeStyle = "rgba(" + col.guide.join(",") + ",0.55)";
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
const a0 = px(A);
ctx.moveTo(a0[0], a0[1]);
const c1p = px(C1), c2p = px(C2), c3p = px(C3), bp = px(B);
ctx.lineTo(c1p[0], c1p[1]);
ctx.lineTo(c2p[0], c2p[1]);
ctx.lineTo(c3p[0], c3p[1]);
ctx.lineTo(bp[0], bp[1]);
ctx.stroke();
ctx.setLineDash([]);
// 실 — 두 부채꼴. 방금 감은 올일수록 밝고 굵다(recency)
ctx.lineCap = "round";
ctx.shadowBlur = 6;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.35)";
let i: number, u: number, recency: number, a: XY, b: XY, alpha: number;
for (i = 0; i < fan; i++) {
u = i / (fan - 1);
if (u > progress) continue;
a = px(lerp(A, C1, u)); b = px(lerp(C3, B, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.44 + 0.5 * Math.pow(recency, 1.1);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.4 + recency * 1.8;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (i = 0; i < fan2; i++) {
u = i / (fan2 - 1);
if (u > progress) continue;
a = px(lerp(C1, C2, u)); b = px(lerp(C2, C3, u));
recency = Math.max(0, 1 - (progress - u));
alpha = (0.38 + 0.5 * Math.pow(recency, 1.1)) * 0.9;
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.9, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.1 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
// 옆 부채꼴 2개 — 사슬 앞쪽(A·C1·C2)과 뒤쪽(C2·C3·B)을 각자 부채로 펼친다
for (i = 0; i < fan3; i++) {
u = i / (fan3 - 1);
if (u > progress) continue;
a = px(lerp(A, C1, u)); b = px(lerp(C1, C2, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.36 + 0.48 * Math.pow(recency, 1.1);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.9, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.2 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (i = 0; i < fan3; i++) {
u = i / (fan3 - 1);
if (u > progress) continue;
a = px(lerp(C2, C3, u)); b = px(lerp(C3, B, u));
recency = Math.max(0, 1 - (progress - u));
alpha = 0.36 + 0.48 * Math.pow(recency, 1.1);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.9, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.2 + recency * 1.5;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 고정핀 — 마름모
const anchors: XY[] = [px(A), px(B)];
for (i = 0; i < 2; i++) {
const an = anchors[i];
ctx.fillStyle = "rgb(" + col.pin.join(",") + ")";
ctx.beginPath();
ctx.moveTo(an[0], an[1] - 5); ctx.lineTo(an[0] + 5, an[1]);
ctx.lineTo(an[0], an[1] + 5); ctx.lineTo(an[0] - 5, an[1]);
ctx.closePath(); ctx.fill();
}
// 컨트롤 핀 — 실색 채운 원 + 광택 고리(끌고 있는 핀은 더 밝게 맥동)
const dragBoost = 0.5 + 0.5 * Math.sin(t * 6);
for (i = 0; i < NCTRL; i++) {
const c = px(cur[i]);
ctx.strokeStyle = "rgba(" + col.hot.join(",") + "," + (0.55 + (drag === i ? 0.45 * dragBoost : 0)).toFixed(3) + ")";
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(c[0], c[1], drag === i ? 10 : 8, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = "rgb(" + col.thrd.join(",") + ")";
ctx.beginPath(); ctx.arc(c[0], c[1], 4.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "rgb(" + col.pin.join(",") + ")";
ctx.beginPath(); ctx.arc(c[0], c[1], 1.8, 0, Math.PI * 2); ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const cur = curRef.current, tgt = tgtRef.current;
if (cur && tgt) {
// 재계산 주기마다 다음 자리로 목표 갱신 — 컨트롤 핀이 스스로 움직여 재계산을 보여준다
const pi = Math.floor(t / recompute) % PRESETS.length;
for (let k = 0; k < NCTRL; k++) {
if (dragRef.current !== k) tgt[k] = PRESETS[pi][k];
cur[k][0] += (tgt[k][0] - cur[k][0]) * 0.09;
cur[k][1] += (tgt[k][1] - cur[k][1]) * 0.09;
}
}
// 주기 안 앞 85% 에 걸쳐 다시 감고, 나머지 구간은 핀 글라이드가 계속 움직인다
const progress = Math.min(1, (t % recompute) / recompute / 0.85);
draw(t, progress);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sd-thread"),
pin: tokRGB(root, "--sd-pin"),
hot: tokRGB(root, "--sd-hot"),
guide: tokRGB(root, "--sd-guide"),
panel: tokRGB(root, "--sd-panel")
};
ctxRef.current = cv.getContext("2d");
fit();
curRef.current = PRESETS[0].map((p): UV => [p[0], p[1]]);
tgtRef.current = PRESETS[0].map((p): UV => [p[0], p[1]]);
// ── 포인터 — 컨트롤 핀 끌기(pointermove 로 실시간 재감기) ──
const cvs: HTMLCanvasElement = cv; // 클로저 안 null narrowing 보존(325 해법 — 지역 별칭)
function evPos(ev: PointerEvent): XY {
const r = cvs.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
}
function hit(x: number, y: number): number {
const cur = curRef.current;
if (!cur) return -1;
for (let k = 0; k < NCTRL; k++) {
const c = px(cur[k]);
const dx = x - c[0], dy = y - c[1];
if (dx * dx + dy * dy < 18 * 18) return k;
}
return -1;
}
function onPointerDown(ev: PointerEvent): void {
const p = evPos(ev);
dragRef.current = hit(p[0], p[1]);
if (dragRef.current >= 0) {
try { cvs.setPointerCapture(ev.pointerId); } catch { /* 캡처 실패해도 드래그는 유지 */ }
ev.preventDefault();
}
}
function onPointerMove(ev: PointerEvent): void {
const drag = dragRef.current;
const cur = curRef.current, tgt = tgtRef.current;
if (drag < 0 || !cur || !tgt) return;
const p = evPos(ev);
const rx = Math.max(0.06, Math.min(0.94, p[0] / wRef.current));
const ry = Math.max(0.10, Math.min(0.90, p[1] / hRef.current));
cur[drag] = [rx, ry]; tgt[drag] = [rx, ry];
ev.preventDefault();
}
function drop(): void { dragRef.current = -1; }
cvs.addEventListener("pointerdown", onPointerDown);
cvs.addEventListener("pointermove", onPointerMove);
cvs.addEventListener("pointerup", drop);
cvs.addEventListener("pointercancel", drop);
function onResize(): void {
fit();
if (reduced()) draw(0, 1);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0, 1); // reduce — 다 감긴 부채꼴 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
cvs.removeEventListener("pointerdown", onPointerDown);
cvs.removeEventListener("pointermove", onPointerMove);
cvs.removeEventListener("pointerup", drop);
cvs.removeEventListener("pointercancel", drop);
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recompute, fan, fan2, fan3]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
aria-label={BOARD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<p className={styles.hint}>
<span className={styles.title}>{HINT_TITLE}</span>
<span className={styles.desc}>{HINT_DESC}</span>
</p>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}04 베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다 — 측정 누적면적 30.967% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스
이 데모가 속한 글
스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스스트링아트는 핀에 실을 감아 곡선과 명암을 만드는 공예인데, 이 글은 감기의 전 과정을 핀을 어디에 박을지부터 실을 어느 순서로 감을지까지 통째로 계산하는 캔버스 엔진 아홉 판을 줍니다. 원 둘레의 핀들 사이를 실이 이 글의 데모 전부 보기


