스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스
스트링아트는 핀에 실을 감아 곡선과 명암을 만드는 공예인데, 이 글은 감기의 전 과정을 핀을 어디에 박을지부터 실을 어느 순서로 감을지까지 통째로 계산하는 캔버스 엔진 아홉 판을 줍니다. 원 둘레의 핀들 사이를 실이 규칙으로 건너뛰면 곡선이 저절로 생깁니다.
01 string-pin-circle-basic
02 string-hyperbola-curve-illusion
03 string-cardioid-circle-math
04 string-bezier-pins-control
05 string-chord-density-photo
06 string-thread-greedy-order
07 string-pins-from-image-edge
08 string-loom-weave-pattern
09 string-thread-decor-hero
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 원핀 기본 — 일정 칸 건너뛰기만으로 꽃이 핀다
- 02 곡선 착시 — 직선 실만으로 원을 그리는 포락선
- 03 마스카레이드 — 원 위 2배수 핀의 수학
- 04 베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다
- 05 사진 실화 — 밝기를 실 밀도로 바꾸는 초상
- 06 욕심쇠 감기 — 다음 실을 고르는 최적화
- 07 윤곽 핀 배치 — 그림의 가장자리에서 핀을 뽑는다
- 08 직조 무늬 — 실이 스치며 만드는 격자 문양
- 09 대문 감기 장면 — 브랜드 글자를 실로 짜는 히어로
데모
아홉 판 전부 파일 하나씩으로 열리자마자 감기를 시작하고, 화면의 모든 변화는 CSS 키프레임이 아니라 핀 좌표와 감기 순서를 다시 계산해 실을 다시 그린 결과입니다. 24프레임 녹화에서 아홉 판 모두 23프레임이 움직였고, 감기 진행이 주기 2초 안에 차오르며 방금 감은 올이 가장 밝아 감는 손의 자리를 눈이 따라갑니다. 04 는 컨트롤 핀을 직접 끌어 보고, 09 는 감기가 끝날 무렵 한 프레임에 면적 43.653% 까지 차오르는 완성 국면까지 지켜볼 판입니다.
24프레임 실측 한눈에
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 원핀 기본 | 120핀 49칸 건너뛰기의 최소 구현 | 30.758 | 40.2 |
| 02 곡선 착시 | 두 변 32쌍 직선 현의 원호 포락 | 30.674 | 40.5 |
| 03 마스카레이드 | 배수 k 2·3·4 순환의 곡선 교실 | 33.049 | 31.9 |
| 04 베지어 감기 | 컨트롤 핀 끌기의 실시간 재감기 | 30.737 | 62.5 |
| 05 사진 실화 | 밝기를 실 밀도로 바꾼 초상 | 30.144 | 51.4 |
| 06 욕심쇠 감기 | 후보 180현 점수 비교의 최적 선택 | 32.857 | 47.0 |
| 07 윤곽 핀 배치 | 그림 경계에서 뽑은 90핀 | 32.144 | 35.9 |
| 08 직조 무늬 | 경사 26·위사 18의 위상차 교차 | 50.97 | 38.6 |
| 09 대문 감기 장면 | 글자 핀 64개로 짜는 워드마크 | 42.744 | 32.8 |
수치는 구성의 성격으로 읽는다
누적면적이 일곱 판에서 30~33% 대에 모이는 것은 실이 원판이나 모양 안쪽에 모이는 구성 탓이고, 08 직조 무늬만 50.97% 로 유일하게 판 전체를 덮습니다. 세로 경사와 가로 위사가 네 모서리까지 실을 걸기 때문입니다. 09 대문 감기는 42.744% 이고 감기가 끝날 무렵 한 프레임이 43.653% 까지 차올라 프레임 면적 기준으로는 아홉 판 중 가장 찹니다. 강도 평균은 04 베지어 감기가 62.5 로 가장 밝은데 굵기 2.4에서 4.2px 까지 굵어지는 실과 방금 감은 올의 대비가 크기 때문이고, 05 사진 실화 51.4 · 06 욕심쇠 감기 47.0 이 뒤따릅니다. 누적면적이 가장 낮은 05(30.144%)는 어두운 곳에만 실을 놓는 초상 구성이라 낮은 것이지 빈 판이 아닙니다.
원 둘레 감기에서 사진 명암까지, 스트링아트 규칙을 넓힌 순서
순서는 화려함의 서열이 아니라 감기 규칙이 어디에서 나오는가를 한 단계씩 넓히는 서열입니다. 먼저 칸수 하나를 정해 원 둘레를 도는 가장 단순한 규칙으로 곡선을 얻고(01), 프레임을 원에서 두 변으로 옮겨 직선이 곡선을 만든다는 원리를 드러냅니다(02). 세 번째는 건너뛰기를 배수 규칙으로 일반화해 규칙 하나가 곡선의 종류를 가르는 것을 보이고(03), 넷째는 반대로 핀 배치가 움직여 규칙 전체가 다시 짜이게 합니다(04). 다섯째부터 규칙이 그림을 읽습니다 — 사진의 어두움이 다음 올을 고르고(05), 그 고르기가 점수 비교의 최적화가 되며(06), 핀 배치 자체를 그림 경계에서 뽑습니다(07). 여덟 번째는 감는 대상이 곡선에서 직조 문양으로 바뀌고(08), 마지막에 글자 핀·스포트라이트까지 얹어 대문 장면으로 조립합니다(09). 스트링아트 도안을 손으로 그리지 않고 계산으로 얻는다는 점에서 평면 무늬 속에 입체를 감추는 매직아이 스테레오그램과 같은 착시 축의 이웃이고, 핀 좌표와 현 밝기를 프레임마다 굽는 방식은 거리장을 한 걸음씩 밟아 장면을 세우는 SDF 레이마칭·먹이 향을 좇아 스스로 그물을 다시 잇는 슬라임 몰드 시뮬레이션과도 계산으로 그림을 얻는 계보입니다.
항목별 코드
01원핀 기본 — 건너뛰기 49칸만으로 꽃이 피는 최소판
원 둘레에 120핀을 박고 i번 핀에서 i+49번 핀으로 한 바퀴 120올을 감는, 스트링아트 규칙의 모든 것이 시작되는 최소 구현입니다. 곡선은 한 번도 그리지 않는데 현이 겹쳐 밝아지는 자리가 원판 안에 장미꼴 포락선을 띄우고, 방금 감은 올은 알파 0.34에서 0.96, 굵기 1에서 2.4px 로 밝아져 감는 손이 지나가는 자리가 드러납니다.

<main class="stage">
<div class="sa is-demo" id="saRoot" role="application" aria-label="원핀 감기판" tabindex="0">
<canvas class="sa__cv" id="saCv"></canvas>
<p class="sa__hint">
<span class="sa__title">원핀 기본</span>
<span class="sa__desc">원 둘레 핀 사이를 정해진 칸수로 건너 실을 감으면 곡선 꽃이 절로 핀다</span>
</p>
<div class="sa__bar">
<span class="sa__meta">핀 120개 · 건너뛰기 49칸 · 실 올 120</span>
<span class="sa__meta sa__meta--dim">sin·cos · lineTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 기본(01) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(감기 진행 재계산)가 만든다.
// 무대 잉크 — 어두운 판 위 파스텔 실로 장미꼴 포락선을 띄운다. 캔버스가 판 전체다.
$sa-stage: #1a1720;
@include stage($sa-stage);
$sa-panel: #241f2d; // 감기판 바닥 — 무대보다 한 단계 밝은 먹보라
$sa-text: #d5cfe6; // 라벨 글자 — 밝은 라일락 회색
$sa-thread: #cfd9e7; // 실 — 파스텔 아이리스 블루
$sa-pin: #f0e6d2; // 감긴 핀 — 크림
$sa-dim: #4d4459; // 아직 안 감긴 핀 — 저채도 보라
$sa-glow: #e8d5e2; // 활 실 끝 광택 — 벚꽃 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $sa-thread;
.sa {
--sa-panel: #{$sa-panel};
--sa-thread: #{$sa-thread};
--sa-pin: #{$sa-pin};
--sa-dim: #{$sa-dim};
--sa-glow: #{$sa-glow};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sa__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sa__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: $sa-text;
}
.sa__title { text-shadow: 0 1px 6px rgba($sa-panel, 1); }
.sa__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sa-text, .78);
text-shadow: 0 1px 6px rgba($sa-panel, 1);
}
.sa__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;
}
.sa__meta {
font-size: 10px; font-weight: 500; color: rgba($sa-text, .78);
background: rgba($sa-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sa:focus-visible { outline: 2px solid $sa-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sa__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 꽃 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sa.is-still { 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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sa {
--sa-panel: #241f2d;
--sa-thread: #cfd9e7;
--sa-pin: #f0e6d2;
--sa-dim: #4d4459;
--sa-glow: #e8d5e2;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sa__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sa__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: #d5cfe6;
}
.sa__title {
text-shadow: 0 1px 6px #241f2d;
}
.sa__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.sa__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sa__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.sa:focus-visible {
outline: 2px solid #cfd9e7;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sa__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sa.is-still {
cursor: default !important;
}
}// 스트링아트 기본(01) — 원 둘레 핀 + 일정 칸 건너뛰기의 최소 구현.
// 핀 i 의 좌표는 (cx + R cos(2πi/N), cy + R sin(2πi/N)).
// 실은 i 번 핀에서 i + SKIP 번 핀으로 한 올씩 감긴다. 직선 현만 그리는데
// 현들이 겹치는 밝기가 원판 안에 장미꼴 포락선을 띄운다.
// 감기 진행량 progress 0→1 이 주기 안에 차오르고, 감는 손이 지나간 올은
// 최근일수록 밝다(광택) — 감기가 어디까지 왔는지 눈이 따라간다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 120; // 핀 개수 — 더 올리면 곡선이 매끈해진다
var SKIP = 49; // 건너뛰기 칸수 — 2로 내리면 꽃잎이 크고 무늬가 성기다(변형 1)
var CYCLE = 2.0; // 감기 완료 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('saRoot');
var cvEl = document.getElementById('saCv');
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('--sa-thread');
var COL_PIN = tokRGB('--sa-pin');
var COL_GLOW = tokRGB('--sa-glow');
var COL_PANEL = tokRGB('--sa-panel');
var COL_DIM = tokRGB('--sa-dim');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0;
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);
R = Math.min(W, H) * 0.46;
}
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2; // 0번 핀을 12시에 두고 시계 방향
return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
// 실 — 감기 순서대로. progress 만큼의 올이 걸려 있다.
var total = N; // 한 바퀴 = N 올 (i → i+SKIP, N번)
var seg = progress * total;
var full = Math.floor(seg);
var frac = seg - full;
ctx.lineCap = 'round';
ctx.shadowBlur = 5; // 실 광택 — 올 주위 헤일로그가 겹침 밝기를 더한다
for (var i = 0; i < full; i++) {
var a = pinXY(i), b = pinXY(i + SKIP);
// 감기 근처의 최근 올은 진하게 — 감는 손이 지나가는 자리가 밝다
var recency = (full - i) / total; // 1(방금) → 0(처음)
var alpha = 0.34 + 0.62 * Math.pow(recency, 1.6);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_GLOW.join(',') + ',' + (alpha * 0.5).toFixed(3) + ')';
ctx.lineWidth = 1 + recency * 1.4;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
// 이어지는 한 올 — 부드러운 진행
if (full < total && frac > 0) {
var a = pinXY(full), b = pinXY(full + SKIP);
ctx.strokeStyle = 'rgba(' + COL_GLOW.join(',') + ',0.95)';
ctx.shadowColor = 'rgba(' + COL_GLOW.join(',') + ',0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac);
ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 실이 지나간 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
var done = p <= full;
if (done) {
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.65 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.4 + 1.2 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgb(' + COL_DIM.join(',') + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2, 0, Math.PI * 2); ctx.fill();
}
}
// 활 실 끝의 광택 — 현재 감는 지점을 알린다(다 감긴 뒤엔 꽃 중심이 숨쉰다)
if (full < total) {
var tip = pinXY(full);
ctx.fillStyle = 'rgba(' + COL_GLOW.join(',') + ',0.9)';
ctx.beginPath(); ctx.arc(tip[0], tip[1], 3.2, 0, Math.PI * 2); ctx.fill();
} else {
var breathe = 0.5 + 0.5 * Math.sin(t * 5);
ctx.fillStyle = 'rgba(' + COL_GLOW.join(',') + ',' + (0.35 + 0.45 * breathe).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(W / 2, H / 2, 4 + 2.5 * breathe, 0, Math.PI * 2); ctx.fill();
}
}
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) % CYCLE;
// 0→0.78 감기 · 0.78→1 유지(다 감긴 꽃을 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.78);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 감긴 꽃 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringPinCircleBasic — vanilla 판(run/326/demo/01-string-pin-circle-basic/index.html) 의 React 포트.
// 원 둘레 핀 사이를 정해진 칸수로 건너 실을 감는 스트링아트 기본.
// 핀 i 의 좌표는 (cx + R·cos(2πi/N), cy + R·sin(2πi/N)). 실은 i 번 핀에서 i+SKIP 번 핀으로
// 한 올씩 감기고, 직선 현만 그리는데 현들이 겹치는 밝기가 원판 안에 장미꼴 포락선을 띄운다.
// progress 0→1 이 주기 안에 차오르고, 감는 손이 지나간 올은 최근일수록 밝다(광택).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringPinCircleBasic.module.scss";
export type StringPinCircleBasicProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 핀 개수 — 더 올리면 곡선이 매끈해진다
skip?: number; // 건너뛰기 칸수 — 2로 내리면 꽃잎이 크고 무늬가 성기다(변형 1)
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 120; // 핀 개수
const SKIP = 49; // 건너뛰기 칸수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
const BOARD_LABEL = "원핀 감기판";
const META_LEFT = "핀 120개 · 건너뛰기 49칸 · 실 올 120";
const META_RIGHT = "sin·cos · lineTo";
const HINT_TITLE = "원핀 기본";
const HINT_DESC = "원 둘레 핀 사이를 정해진 칸수로 건너 실을 감으면 곡선 꽃이 절로 핀다";
export default function StringPinCircleBasic({
duration = "2s",
easing = "ease-out",
color = "#cfd9e7",
n = N,
skip = SKIP,
cycle = CYCLE
}: StringPinCircleBasicProps) {
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; glow: RGB; panel: RGB; dim: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const rafRef = useRef(0) 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 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);
rRef.current = Math.min(wRef.current, hRef.current) * 0.46;
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2; // 0번 핀을 12시에 두고 시계 방향
return [wRef.current / 2 + Math.cos(a) * rRef.current, hRef.current / 2 + Math.sin(a) * rRef.current];
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
// 실 — 감기 순서대로. progress 만큼의 올이 걸려 있다.
const total = n; // 한 바퀴 = n 올 (i → i+SKIP, n번)
const seg = progress * total;
const full = Math.floor(seg);
const frac = seg - full;
ctx.lineCap = "round";
ctx.shadowBlur = 5; // 실 광택 — 올 주위 헤일로그가 겹침 밝기를 더한다
for (let i = 0; i < full; i++) {
const a = pinXY(i), b = pinXY(i + skip);
// 감기 근처의 최근 올은 진하게 — 감는 손이 지나가는 자리가 밝다
const recency = (full - i) / total; // 1(방금) → 0(처음)
const alpha = 0.34 + 0.62 * Math.pow(recency, 1.6);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + alpha.toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.glow.join(",") + "," + (alpha * 0.5).toFixed(3) + ")";
ctx.lineWidth = 1 + recency * 1.4;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
// 이어지는 한 올 — 부드러운 진행
if (full < total && frac > 0) {
const a = pinXY(full), b = pinXY(full + skip);
ctx.strokeStyle = "rgba(" + col.glow.join(",") + ",0.95)";
ctx.shadowColor = "rgba(" + col.glow.join(",") + ",0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac);
ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 실이 지나간 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
const done = p <= full;
if (done) {
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.65 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.4 + 1.2 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgb(" + col.dim.join(",") + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2, 0, Math.PI * 2); ctx.fill();
}
}
// 활 실 끝의 광택 — 현재 감는 지점을 알린다(다 감긴 뒤엔 꽃 중심이 숨쉰다)
if (full < total) {
const tip = pinXY(full);
ctx.fillStyle = "rgba(" + col.glow.join(",") + ",0.9)";
ctx.beginPath(); ctx.arc(tip[0], tip[1], 3.2, 0, Math.PI * 2); ctx.fill();
} else {
const breathe = 0.5 + 0.5 * Math.sin(t * 5);
ctx.fillStyle = "rgba(" + col.glow.join(",") + "," + (0.35 + 0.45 * breathe).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(W / 2, H / 2, 4 + 2.5 * breathe, 0, Math.PI * 2); ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.78 감기 · 0.78→1 유지(다 감긴 꽃을 볼 시간)
const progress = Math.min(1, t / cycle / 0.78);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sa-thread"),
pin: tokRGB(root, "--sa-pin"),
glow: tokRGB(root, "--sa-glow"),
panel: tokRGB(root, "--sa-panel"),
dim: tokRGB(root, "--sa-dim")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 감긴 꽃 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, skip, cycle]);
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>
);
}01 · 원핀 기본 — 일정 칸 건너뛰기만으로 꽃이 핀다 — 측정 누적면적 30.685% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
등각 핀 배치와 감기 루프
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2; // 0번 핀을 12시에 두고 시계 방향
return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
}
// 실 — 감기 순서대로. progress 만큼의 올이 걸려 있다.
var total = N; // 한 바퀴 = N 올 (i → i+SKIP, N번)
var seg = progress * total;
var full = Math.floor(seg);
ctx.lineCap = 'round';
ctx.shadowBlur = 5; // 실 광택 — 올 주위 헤일로그가 겹침 밝기를 더한다
for (var i = 0; i < full; i++) {
var a = pinXY(i), b = pinXY(i + SKIP);
// 감기 근처의 최근 올은 진하게 — 감는 손이 지나가는 자리가 밝다
var recency = (full - i) / total; // 1(방금) → 0(처음)
var alpha = 0.34 + 0.62 * Math.pow(recency, 1.6);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_GLOW.join(',') + ',' + (alpha * 0.5).toFixed(3) + ')';
ctx.lineWidth = 1 + recency * 1.4;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
02곡선 착시 — 직선 현만으로 원호가 떠오르는 두 변 감기
왼변 32핀과 아래변 32핀을 박고 왼변 i번을 아래변 (M−1−i)번과 이으면 모든 현이 한 원호에 닿는, 직선의 포락선이 곡선을 만든다는 원리의 정석 구성입니다. 곡선을 그리는 호출은 단 한 번도 없는데 눈은 원호를 봅니다.

<main class="stage">
<div class="sb is-demo" id="sbRoot" role="application" aria-label="포락선 감기판" tabindex="0">
<canvas class="sb__cv" id="sbCv"></canvas>
<p class="sb__hint">
<span class="sb__title">곡선 착시</span>
<span class="sb__desc">두 변의 핀 사이를 촘촘히 건너 감으면 어디에도 없는 원호가 떠오른다</span>
</p>
<div class="sb__bar">
<span class="sb__meta">핀 32쌍 · 곡선 원호 · 주기 2초</span>
<span class="sb__meta sb__meta--dim">lineTo · 포락선</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 곡선 착시(02) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(감기 진행 재계산)가 만든다.
// 무대 노랑 — 밝은 판 위 잉크보라 실로 원호 포락선을 띄운다. 캔버스가 판 전체다.
$sb-stage: #ffe9a8;
@include stage($sb-stage);
$sb-panel: #fff7d6; // 감기판 바닥 — 무대보다 한 단계 밝은 크림옐로
$sb-text: #4a3f14; // 라벨 글자 — 진옐로 잉크
$sb-thread: #5a5470; // 실 — 잉크보라(밝은 판 위 직선)
$sb-pin: #2e2a3a; // 핀 — 먹
$sb-dim: #cdbd8a; // 아직 안 감긴 핀 — 저채도 모래
$sb-guide: #b3a770; // 두 변 가이드 — 저채도 올리브
$duration: $dur-loop;
$easing: $ease-out;
$color: $sb-thread;
.sb {
--sb-panel: #{$sb-panel};
--sb-thread: #{$sb-thread};
--sb-pin: #{$sb-pin};
--sb-dim: #{$sb-dim};
--sb-guide: #{$sb-guide};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sb__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sb__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: $sb-text;
}
.sb__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sb-text, .78);
}
.sb__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;
}
.sb__meta {
font-size: 10px; font-weight: 500; color: rgba($sb-text, .78);
background: rgba($sb-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sb:focus-visible { outline: 2px solid $sb-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sb__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 포락선 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sb.is-still { 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: #ffe9a8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
--sb-panel: #fff7d6;
--sb-thread: #5a5470;
--sb-pin: #2e2a3a;
--sb-dim: #cdbd8a;
--sb-guide: #b3a770;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sb__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sb__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: #4a3f14;
}
.sb__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 63, 20, 0.78);
}
.sb__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sb__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 63, 20, 0.78);
background: rgba(255, 247, 214, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.sb:focus-visible {
outline: 2px solid #5a5470;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sb__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sb.is-still {
cursor: default !important;
}
}// 스트링아트 곡선 착시(02) — 직선 현만으로 곡선을 만드는 포락선 원리.
// 왼변·아래변에 핀을 나란히 박고, 왼변의 i 번 핀을 아래변의 (M-1-i) 번 핀과 이으면
// 모든 현이 한 원호에 닿는다 — 현의 집합이 원호를 포락한다.
// 곡선을 한 번도 그리지 않았는데 눈은 원호를 본다. 감기가 차오르는 과정이 애니메이션이다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var M = 32; // 변마다 핀 개수 — 더 올리면 호가 더 매끈해진다
var CYCLE = 2.0; // 감기 완료 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('sbRoot');
var cvEl = document.getElementById('sbCv');
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('--sb-thread');
var COL_PIN = tokRGB('--sb-pin');
var COL_GUIDE = tokRGB('--sb-guide');
var COL_PANEL = tokRGB('--sb-panel');
var COL_DIM = tokRGB('--sb-dim');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var pad = 0;
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);
pad = Math.min(W, H) * 0.07;
}
// 왼변 핀 i (위에서 아래) — 판 세로를 전부 쓴다
function pinL(i) { return [pad, pad + (H - 2 * pad) * i / (M - 1)]; }
// 아래변 핀 j (왼쪽에서 오른쪽) — 판 가로를 전부 쓴다
function pinB(j) { return [pad + (W - 2 * pad) * j / (M - 1), H - pad]; }
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
// 두 변 표시 — 실이 어느 틀에 감기는지 보여주는 가이드
ctx.strokeStyle = 'rgba(' + COL_GUIDE.join(',') + ',0.5)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, H - pad); ctx.lineTo(W - pad, H - pad); ctx.stroke();
// 실 — 왼변 i ↔ 아래변 (M-1-i). 감는 손이 지나간 자리일수록 밝다
ctx.lineCap = 'round';
ctx.shadowBlur = 5;
for (var i = 0; i < M; i++) {
if (i / M >= progress) continue;
var a = pinL(i), b = pinB(M - 1 - i);
var recency = (progress * M - i) / M; // 1(방금 감은 올) → 0(처음 올)
var alpha = 0.55 + 0.42 * Math.pow(recency, 1.4);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
ctx.lineWidth = 1.8 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
for (var p = 0; p < M; p++) {
var la = pinL(p), ba = pinB(p);
var doneL = p / M < progress;
var doneB = (M - 1 - p) / M < progress;
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.4);
if (doneL) {
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.65 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(la[0], la[1], 1.5 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgb(' + COL_DIM.join(',') + ')';
ctx.beginPath(); ctx.arc(la[0], la[1], 1.3, 0, Math.PI * 2); ctx.fill();
}
if (doneB) {
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.65 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(ba[0], ba[1], 1.5 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgb(' + COL_DIM.join(',') + ')';
ctx.beginPath(); ctx.arc(ba[0], ba[1], 1.3, 0, Math.PI * 2); ctx.fill();
}
}
}
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) % CYCLE;
// 0→0.75 감기 · 0.75→1 유지(다 감긴 착시 원호를 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.75);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 감긴 포락선 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringHyperbolaCurveIllusion — vanilla 판(run/326/demo/02-string-hyperbola-curve-illusion/index.html) 의 React 포트.
// 직선 현만으로 곡선을 만드는 포락선 원리. 왼변·아래변에 핀을 나란히 박고,
// 왼변의 i 번 핀을 아래변의 (M-1-i) 번 핀과 이으면 모든 현이 한 원호에 닿는다 —
// 현의 집합이 원호를 포락한다. 곡선을 한 번도 그리지 않았는데 눈은 원호를 본다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringHyperbolaCurveIllusion.module.scss";
export type StringHyperbolaCurveIllusionProps = {
duration?: string;
easing?: string;
color?: string;
m?: number; // 변마다 핀 개수 — 더 올리면 호가 더 매끈해진다
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const M = 32; // 변마다 핀 개수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
const BOARD_LABEL = "포락선 감기판";
const META_LEFT = "핀 32쌍 · 곡선 원호 · 주기 2초";
const META_RIGHT = "lineTo · 포락선";
const HINT_TITLE = "곡선 착시";
const HINT_DESC = "두 변의 핀 사이를 촘촘히 건너 감으면 어디에도 없는 원호가 떠오른다";
export default function StringHyperbolaCurveIllusion({
duration = "2s",
easing = "ease-out",
color = "#5a5470",
m = M,
cycle = CYCLE
}: StringHyperbolaCurveIllusionProps) {
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; guide: RGB; panel: RGB; dim: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const padRef = useRef(0) as Box<number>;
const rafRef = useRef(0) 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 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);
padRef.current = Math.min(wRef.current, hRef.current) * 0.07;
}
// 왼변 핀 i (위에서 아래) — 판 세로를 전부 쓴다
function pinL(i: number): XY {
const pad = padRef.current, H = hRef.current;
return [pad, pad + (H - 2 * pad) * i / (m - 1)];
}
// 아래변 핀 j (왼쪽에서 오른쪽) — 판 가로를 전부 쓴다
function pinB(j: number): XY {
const pad = padRef.current, W = wRef.current;
return [pad + (W - 2 * pad) * j / (m - 1), hRef.current - pad];
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current, pad = padRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
// 두 변 표시 — 실이 어느 틀에 감기는지 보여주는 가이드
ctx.strokeStyle = "rgba(" + col.guide.join(",") + ",0.5)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, H - pad); ctx.lineTo(W - pad, H - pad); ctx.stroke();
// 실 — 왼변 i ↔ 아래변 (m-1-i). 감는 손이 지나간 자리일수록 밝다
ctx.lineCap = "round";
ctx.shadowBlur = 5;
for (let i = 0; i < m; i++) {
if (i / m >= progress) continue;
const a = pinL(i), b = pinB(m - 1 - i);
const recency = (progress * m - i) / m; // 1(방금 감은 올) → 0(처음 올)
const alpha = 0.55 + 0.42 * Math.pow(recency, 1.4);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + alpha.toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.thrd.join(",") + "," + (alpha * 0.45).toFixed(3) + ")";
ctx.lineWidth = 1.8 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
for (let p = 0; p < m; p++) {
const la = pinL(p), ba = pinB(p);
const doneL = p / m < progress;
const doneB = (m - 1 - p) / m < progress;
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.4);
if (doneL) {
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.65 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(la[0], la[1], 1.5 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgb(" + col.dim.join(",") + ")";
ctx.beginPath(); ctx.arc(la[0], la[1], 1.3, 0, Math.PI * 2); ctx.fill();
}
if (doneB) {
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.65 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(ba[0], ba[1], 1.5 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgb(" + col.dim.join(",") + ")";
ctx.beginPath(); ctx.arc(ba[0], ba[1], 1.3, 0, Math.PI * 2); ctx.fill();
}
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.75 감기 · 0.75→1 유지(다 감긴 착시 원호를 볼 시간)
const progress = Math.min(1, t / cycle / 0.75);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sb-thread"),
pin: tokRGB(root, "--sb-pin"),
guide: tokRGB(root, "--sb-guide"),
panel: tokRGB(root, "--sb-panel"),
dim: tokRGB(root, "--sb-dim")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 감긴 포락선 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [m, cycle]);
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>
);
}02 · 곡선 착시 — 직선 실만으로 원을 그리는 포락선 — 측정 누적면적 30.669% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 변의 역순 짝짓기
// 왼변 핀 i (위에서 아래) — 판 세로를 전부 쓴다
function pinL(i) { return [pad, pad + (H - 2 * pad) * i / (M - 1)]; }
// 아래변 핀 j (왼쪽에서 오른쪽) — 판 가로를 전부 쓴다
function pinB(j) { return [pad + (W - 2 * pad) * j / (M - 1), H - pad]; }
// 실 — 왼변 i ↔ 아래변 (M-1-i). 감는 손이 지나간 자리일수록 밝다
ctx.lineCap = 'round';
ctx.shadowBlur = 5;
for (var i = 0; i < M; i++) {
if (i / M >= progress) continue;
var a = pinL(i), b = pinB(M - 1 - i);
var recency = (progress * M - i) / M; // 1(방금 감은 올) → 0(처음 올)
var alpha = 0.55 + 0.42 * Math.pow(recency, 1.4);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
ctx.lineWidth = 1.8 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
03마스카레이드 — 배수 k 하나로 곡선이 갈리는 수학판
원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙 하나로 곡선의 종류가 정해지는데 k=2 는 카디오이드(마스카레이드), k=3 은 네프로이드, k=4 는 3꽃 에피사이클로이드입니다. k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜이고, 구간 앞 70% 에 감아 뒤 30% 는 다 감긴 곡선을 보여 줍니다.

<main class="stage">
<div class="sc is-demo" id="scRoot" role="application" aria-label="배수 규칙 실험판" tabindex="0">
<canvas class="sc__cv" id="scCv"></canvas>
<p class="sc__hint">
<span class="sc__title">마스카레이드</span>
<span class="sc__desc">n번 핀에서 k배 핀으로 감는 규칙 하나로 곡선의 종류가 정해진다</span>
</p>
<span class="sc__rule" id="scRule">규칙 k = 2 · 곡선 마스카레이드</span>
<div class="sc__bar">
<span class="sc__meta">핀 120개 · 배수 k 순환 2·3·4 · 주기 2초</span>
<span class="sc__meta sc__meta--dim">sin·cos · strokeStyle</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 마스카레이드(03) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(k 규칙 재계산)가 만든다.
// 무대 로즈 파스텔 — 따뜻한 판 위 크림 실로 심장 곡선을 띄운다. 캔버스가 판 전체다.
$sc-stage: #c49d92;
@include stage($sc-stage);
$sc-panel: #d0aa9f; // 감기판 바닥 — 무대보다 한 단계 밝은 연로즈
$sc-text: #4a1d0d; // 라벨 글자 — 진적갈 잉크
$sc-thread: #fdf3e0; // 실 — 크림(연로즈 판 위 직선)
$sc-hot: #fffbef; // 포락선 꼭짓점쪽 현 — 더 밝은 크림
$sc-pin: #3a180b; // 핀 — 진갈먹
$duration: $dur-loop;
$easing: $ease-out;
$color: $sc-thread;
.sc {
--sc-thread: #{$sc-thread};
--sc-hot: #{$sc-hot};
--sc-pin: #{$sc-pin};
--sc-panel: #{$sc-panel};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sc__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sc__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: $sc-text;
}
.sc__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sc-text, .78);
}
.sc__rule {
position: absolute; z-index: 1;
left: $sp-2; bottom: $sp-6;
font-weight: 600; font-size: 10px; line-height: 1.4;
color: $sc-text;
background: rgba($sc-panel, .9);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sc__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;
}
.sc__meta {
font-size: 10px; font-weight: 500; color: rgba($sc-text, .78);
background: rgba($sc-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sc:focus-visible { outline: 2px solid $sc-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sc__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 k=2 카디오이드 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sc.is-still { 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: #c49d92;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
--sc-thread: #fdf3e0;
--sc-hot: #fffbef;
--sc-pin: #3a180b;
--sc-panel: #d0aa9f;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sc__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sc__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: #4a1d0d;
}
.sc__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 29, 13, 0.78);
}
.sc__rule {
position: absolute;
z-index: 1;
left: 8px;
bottom: 24px;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
color: #4a1d0d;
background: rgba(208, 170, 159, 0.9);
border-radius: 4px;
padding: 4px 4px;
}
.sc__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sc__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 29, 13, 0.78);
background: rgba(208, 170, 159, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.sc:focus-visible {
outline: 2px solid #fdf3e0;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sc__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-still {
cursor: default !important;
}
}// 스트링아트 마스카레이드(03) — 원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙의 수학.
// k=2 는 카디오이드(마스카레이드 곡선), k=3 은 네프로이드, k=4 는 3꽃잎 에피사이클로이드.
// 현들이 겹쳐 밝아지는 자리가 이 곡선들을 포락한다 — 극방정식 계열의 하필(下匹) 현 밝기 계산.
// k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 120; // 핀 개수 — 60으로 내리면 곡선이 각져 보인다(변형 2)
var CYCLE = 2.0; // k 구간 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('scRoot');
var cvEl = document.getElementById('scCv');
var ruleEl = document.getElementById('scRule');
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('--sc-thread');
var COL_PIN = tokRGB('--sc-pin');
var COL_HOT = tokRGB('--sc-hot');
var COL_PANEL = tokRGB('--sc-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0;
var RULE_NAMES = ['카디오이드', '네프로이드', '3꽃 에피사이클로이드'];
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);
R = Math.min(W, H) * 0.54;
}
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
}
// k 를 구간마다 2→3→4 로 바꾼다. 감는 동안은 현재 구간의 k 로 전부 감고,
// 구간이 바뀌는 순간 곡선 전체가 다시 짜인다(실을 다 감는 것과 같은 계산).
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var phase = (t / CYCLE) % 3; // 0..3 — 구간마다 k
var k = 2 + Math.floor(phase); // 2, 3, 4
var local = phase - Math.floor(phase); // 구간 안 진행 0..1
var label = '규칙 k = ' + k + ' · 곡선 ' + RULE_NAMES[k - 2];
// 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
var progress = Math.min(1, local / 0.7);
var total = N; // 한 번에 보이는 올 수
var full = Math.floor(progress * total);
ctx.lineCap = 'round';
ctx.shadowBlur = 5;
for (var i = 0; i < full; i++) {
var a = pinXY(i), b = pinXY((i * k) % N);
// 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
var mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
var near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / R);
var recency = (full - i) / total;
var hot = near > 0.55;
var alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
var base = hot ? COL_HOT : COL_THRD;
ctx.strokeStyle = 'rgba(' + base.join(',') + ',' + Math.min(0.97, alpha).toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_HOT.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 순차적으로 숨쉰다(유지 구간에도 맥동)
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.55 + 0.4 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
}
if (ruleEl) ruleEl.textContent = label;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.45);
});
fit();
if (reduce) {
draw(0.45); // reduce — k=2 카디오이드 다 감긴 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringCardioidCircleMath — vanilla 판(run/326/demo/03-string-cardioid-circle-math/index.html) 의 React 포트.
// 원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙의 수학. k=2 는 카디오이드(마스카레이드 곡선),
// k=3 은 네프로이드, k=4 는 3꽃잎 에피사이클로이드. 현들이 겹쳐 밝아지는 자리가 이 곡선들을
// 포락한다. k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringCardioidCircleMath.module.scss";
export type StringCardioidCircleMathProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 핀 개수 — 60으로 내리면 곡선이 각져 보인다(변형 2)
cycle?: number; // k 구간 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 120; // 핀 개수
const CYCLE = 2.0; // k 구간 주기(초) — 설정 초(2s)와 같아 한 구간이 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
const BOARD_LABEL = "배수 규칙 실험판";
const META_LEFT = "핀 120개 · 배수 k 순환 2·3·4 · 주기 2초";
const META_RIGHT = "sin·cos · strokeStyle";
const HINT_TITLE = "마스카레이드";
const HINT_DESC = "n번 핀에서 k배 핀으로 감는 규칙 하나로 곡선의 종류가 정해진다";
const RULE_NAMES = ["카디오이드", "네프로이드", "3꽃 에피사이클로이드"];
export default function StringCardioidCircleMath({
duration = "2s",
easing = "ease-out",
color = "#fdf3e0",
n = N,
cycle = CYCLE
}: StringCardioidCircleMathProps) {
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; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [rule, setRule] = useState("규칙 k = 2 · 곡선 카디오이드");
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 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);
rRef.current = Math.min(wRef.current, hRef.current) * 0.54;
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [wRef.current / 2 + Math.cos(a) * rRef.current, hRef.current / 2 + Math.sin(a) * rRef.current];
}
// k 를 구간마다 2→3→4 로 바꾼다. 감는 동안은 현재 구간의 k 로 전부 감고,
// 구간이 바뀌는 순간 곡선 전체가 다시 짜인다(실을 다 감는 것과 같은 계산).
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const phase = (t / cycle) % 3; // 0..3 — 구간마다 k
const k = 2 + Math.floor(phase); // 2, 3, 4
const local = phase - Math.floor(phase); // 구간 안 진행 0..1
const label = "규칙 k = " + k + " · 곡선 " + RULE_NAMES[k - 2];
// 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
const progress = Math.min(1, local / 0.7);
const total = n; // 한 번에 보이는 올 수
const full = Math.floor(progress * total);
ctx.lineCap = "round";
ctx.shadowBlur = 5;
for (let i = 0; i < full; i++) {
const a = pinXY(i), b = pinXY((i * k) % n);
// 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
const mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
const near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / rRef.current);
const recency = (full - i) / total;
const hot = near > 0.55;
const alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
const base = hot ? col.hot : col.thrd;
ctx.strokeStyle = "rgba(" + base.join(",") + "," + Math.min(0.97, alpha).toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.hot.join(",") + "," + (alpha * 0.45).toFixed(3) + ")";
ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 순차적으로 숨쉰다(유지 구간에도 맥동)
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.55 + 0.4 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
}
setRule(label); // 규칙 라벨 — vanilla 판의 ruleEl.textContent 갱신에 해당
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sc-thread"),
pin: tokRGB(root, "--sc-pin"),
hot: tokRGB(root, "--sc-hot"),
panel: tokRGB(root, "--sc-panel")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.45);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.45); // reduce — k=2 카디오이드 다 감긴 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, cycle]);
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>
<span className={styles.rule}>{rule}</span>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 · 마스카레이드 — 원 위 2배수 핀의 수학 — 측정 누적면적 33.918% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
배수 규칙과 구간 순환
var phase = (t / CYCLE) % 3; // 0..3 — 구간마다 k
var k = 2 + Math.floor(phase); // 2, 3, 4
var local = phase - Math.floor(phase); // 구간 안 진행 0..1
// 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
var progress = Math.min(1, local / 0.7);
var total = N; // 한 번에 보이는 올 수
var full = Math.floor(progress * total);
ctx.lineCap = 'round';
ctx.shadowBlur = 5;
for (var i = 0; i < full; i++) {
var a = pinXY(i), b = pinXY((i * k) % N);
// 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
var mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
var near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / R);
var recency = (full - i) / total;
var hot = near > 0.55;
var alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
var base = hot ? COL_HOT : COL_THRD;
ctx.strokeStyle = 'rgba(' + base.join(',') + ',' + Math.min(0.97, alpha).toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_HOT.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
04베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다
양끝 고정핀 A·B 와 컨트롤 핀 3개를 사슬로 잇고 사슬의 같은 비율 점끼리 실을 걸면 실의 포락선이 베지어 곡선을 따라가는 드 카스텔료 구성입니다. 핀을 끌면 그 자리에서 다시 감기고 놔두면 1.5초마다 핀이 다음 자리로 옮겨 128+96+84×2올의 부채꼴이 재계산되는데, 굵은 실 탓에 강도평균 62.5 로 아홉 판 중 가장 밝습니다.

<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 포함 · 비밀번호 보기
같은 비율 점을 잇는 부채꼴
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]; }
// 실 — 두 부채꼴. 방금 감은 올일수록 밝고 굵다(recency)
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();
}
// 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;
}
05사진 실화 — 밝기를 실 밀도로 바꾸는 초상
오프스크린에 프로필 실루엣을 절차적으로 그려 96×96 격자의 어두움으로 읽고, 매 올을 후보 4개(SKIP 74 의 1~4배) 중 지나는 길이 더 어두운 쪽으로 연결하는 결정적 욕심 스케줄로 600올을 감습니다. 어두운 영역에만 실이 쌓여 초상이 떠오르고 실 알파·굵기는 그 길의 어두움에 비례합니다.

<main class="stage">
<div class="se is-demo" id="seRoot" role="application" aria-label="밝기 실밀도판" tabindex="0">
<canvas class="se__cv" id="seCv"></canvas>
<p class="se__hint">
<span class="se__title">사진 실화</span>
<span class="se__desc">어두운 곳에 실을 많이 감겠다 매번 고르면 얼굴이 실로 떠오른다</span>
</p>
<div class="se__bar">
<span class="se__meta">프로필 사진 180핀 · 실 600올 · 주기 2초</span>
<span class="se__meta se__meta--dim">getImageData · 밝기</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 사진 실화(05) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(욕심 감기 진행)가 만든다.
// 무대 잉크 — 어두운 판 위 아마 실로 프로필 명암을 띄운다. 캔버스가 판 전체다.
$se-stage: #1a1720;
@include stage($se-stage);
$se-panel: #241f2d; // 감기판 바닥 — 무대보다 한 단계 밝은 먹보라
$se-text: #d5cfe6; // 라벨 글자 — 밝은 라일락 회색
$se-thread: #d9c9a3; // 실 — 아마 골드 파스텔(어두운 판 위 밝은 올)
$se-pin: #f0e6d2; // 감긴 핀 — 크림
$se-dim: #4d4459; // 아직 안 감긴 핀 — 저채도 보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $se-thread;
.se {
--se-panel: #{$se-panel};
--se-thread: #{$se-thread};
--se-pin: #{$se-pin};
--se-dim: #{$se-dim};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.se__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.se__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: $se-text;
text-shadow: 0 1px 6px rgba($se-panel, 1);
}
.se__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($se-text, .78);
text-shadow: 0 1px 6px rgba($se-panel, 1);
}
.se__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;
}
.se__meta {
font-size: 10px; font-weight: 500; color: rgba($se-text, .78);
background: rgba($se-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.se:focus-visible { outline: 2px solid $se-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.se__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 실 초상 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.se.is-still { 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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.se {
--se-panel: #241f2d;
--se-thread: #d9c9a3;
--se-pin: #f0e6d2;
--se-dim: #4d4459;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.se__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.se__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: #d5cfe6;
text-shadow: 0 1px 6px #241f2d;
}
.se__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.se__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.se__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.se:focus-visible {
outline: 2px solid #d9c9a3;
outline-offset: -2px;
}
@media (max-width: 340px) {
.se__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.se.is-still {
cursor: default !important;
}
}// 스트링아트 사진 실화(05) — 밝기를 실 밀도로 바꾸는 명암 변환.
// 외부 이미지 없이 오프스크린 캔버스에 프로필 실루엣(머리·어깨)을 절차적으로 그리고
// getImageData 로 밝기를 읽는다. 감을 올 후보 둘 중 "지나는 길이 더 어두운 쪽"을
// 매번 골라 걸어가면(욕심 선택) 어두운 영역에만 실이 쌓여 초상이 떠오른다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 180; // 원 둘레 핀 개수
var STR = 600; // 감는 실 올 수
var CYCLE = 2.0; // 감기 완료 주기(초)
var SS = 96; // 오프스크린 밝기 격자 크기(SS×SS)
// ────────────────────────────────────────────────────
var root = document.getElementById('seRoot');
var cvEl = document.getElementById('seCv');
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('--se-thread');
var COL_PIN = tokRGB('--se-pin');
var COL_PANEL = tokRGB('--se-panel');
var COL_DIM = tokRGB('--se-dim');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0, CX = 0, CY = 0;
// ── 오프스크린 프로필 실루엣 → 밝기 격자 ──
// 머리(원) + 목 + 어깨(둥근 사다리꼴, 판 아래까지) + 뺨 음영 + 하단 좌우 배경 칼럼 — 절차적으로만 그린다.
var lum = new Float32Array(SS * SS);
(function buildProfile() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
o.fillStyle = '#fff'; o.fillRect(0, 0, SS, SS); // 바탕 밝게
o.fillStyle = '#000';
// 하단 좌우 배경 칼럼 — 사진 테두리 어둡기. 아래쪽에도 실이 감기게 하여 판이 비지 않게
o.fillRect(0, SS * 0.80, SS * 0.20, SS * 0.20);
o.fillRect(SS * 0.80, SS * 0.80, SS * 0.20, SS * 0.20);
// 머리 — 중앙 위쪽 큰 원
o.beginPath(); o.arc(SS * 0.50, SS * 0.30, SS * 0.155, 0, Math.PI * 2); o.fill();
// 목
o.fillRect(SS * 0.455, SS * 0.40, SS * 0.09, SS * 0.10);
// 어깨 — 둥근 사다리꼴, 판 아래까지 이어진다
o.beginPath();
o.moveTo(SS * 0.16, SS * 0.94);
o.quadraticCurveTo(SS * 0.22, SS * 0.585, SS * 0.44, SS * 0.50);
o.lineTo(SS * 0.56, SS * 0.50);
o.quadraticCurveTo(SS * 0.78, SS * 0.585, SS * 0.84, SS * 0.94);
o.lineTo(SS * 0.16, SS * 0.94);
o.closePath(); o.fill();
// 머리카락 음영 — 위쪽 반달 더 어둡게(중첩)
o.beginPath(); o.arc(SS * 0.50, SS * 0.325, SS * 0.158, Math.PI, Math.PI * 2); o.fill();
// 뺨·턱 음영 — 살짝 어두운 타원 2
o.fillStyle = '#555';
o.beginPath(); o.ellipse(SS * 0.445, SS * 0.365, SS * 0.026, SS * 0.017, 0.3, 0, Math.PI * 2); o.fill();
o.beginPath(); o.ellipse(SS * 0.555, SS * 0.365, SS * 0.026, SS * 0.017, -0.3, 0, Math.PI * 2); o.fill();
var d = o.getImageData(0, 0, SS, SS).data;
for (var i = 0; i < SS * SS; i++) {
lum[i] = 1 - d[i * 4] / 255; // 어두움 0..1
}
})();
// 감기 순서 — 매 올을 "남은 후보 중 길이 어두움이 큰 쪽"으로 연결하는
// 결정적 욕심 스케줄(같은 배열이라 매번 같은 그림이 나오도록 시드 고정).
// 계산용 고정 좌표(스케줄은 판 크기와 무관하게 0..1 비율 격자 위에서 짠다)
var SKIP = Math.round(N * 0.41);
var PINU = [];
for (var pi = 0; pi < N; pi++) {
var pa = Math.PI * 2 * pi / N - Math.PI / 2;
PINU.push([0.5 + Math.cos(pa) * 0.5, 0.5 + Math.sin(pa) * 0.5]);
}
function pinXY0(i) { return [PINU[i][0] * SS, PINU[i][1] * SS]; }
function chordDark0(a, b) {
var s = 0, n = 7;
for (var k = 1; k <= n; k++) {
var u = k / (n + 1);
var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
var gx = Math.round(x), gy = Math.round(y);
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
s += lum[gy * SS + gx];
}
return s / n;
}
var schedule = [];
(function plan() {
var pos = 0, used = {};
for (var s = 0; s < STR; s++) {
var bestJ = -1, bestV = -1;
var base = pinXY0(pos);
for (var c = 1; c <= 4; c++) {
var j = (pos + c * SKIP) % N;
var key = pos + '-' + j;
if (used[key]) continue;
var v = chordDark0(base, pinXY0(j));
if (v > bestV) { bestV = v; bestJ = j; }
}
if (bestJ < 0) bestJ = (pos + SKIP) % N;
used[pos + '-' + bestJ] = 1;
schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
pos = bestJ;
}
})();
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [CX + Math.cos(a) * R, CY + Math.sin(a) * R];
}
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);
R = Math.min(W, H) * 0.47;
CX = W / 2; CY = H / 2 + Math.min(H, W) * 0.02;
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var full = Math.floor(progress * STR);
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
for (var i = 0; i < full; i++) {
var seg = schedule[i];
var a = pinXY(seg[0]), b = pinXY(seg[1]);
var dens = seg[2]; // 이 올이 지나는 길의 어두움
var recency = (full - i) / STR;
// 어두운 길의 올은 진하게, 밝은 길의 올은 성겁게 — 밀도가 명암이 된다
var alpha = 0.24 + 0.62 * dens + 0.2 * Math.pow(recency, 1.4);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.3).toFixed(3) + ')';
ctx.lineWidth = 1.1 + dens * 1.1 + recency * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감기가 지난 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
var lim = full > 0 ? schedule[full - 1][1] : 0;
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
if (p <= lim) {
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.6 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.0 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgb(' + COL_DIM.join(',') + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
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) % CYCLE;
// 0→0.8 감기 · 0.8→1 유지(다 감긴 초상을 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.8);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 감긴 실 초상 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringChordDensityPhoto — vanilla 판(run/326/demo/05-string-chord-density-photo/index.html) 의 React 포트.
// 밝기를 실 밀도로 바꾸는 명암 변환. 외부 이미지 없이 오프스크린 캔버스에 프로필 실루엣
// (머리·어깨)을 절차적으로 그리고 getImageData 로 밝기를 읽는다. 감을 올 후보 둘 중
// "지나는 길이 더 어두운 쪽"을 매번 골라 걸어가면(욕심 선택) 어두운 영역에만 실이
// 쌓여 초상이 떠오른다. 스케줄은 0..1 비율 격자 위에서 미리 계산해 둔다(결정적).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringChordDensityPhoto.module.scss";
export type StringChordDensityPhotoProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 원 둘레 핀 개수
str?: number; // 감는 실 올 수
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 180; // 원 둘레 핀 개수
const STR = 600; // 감는 실 올 수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 96; // 오프스크린 밝기 격자 크기(SS×SS)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
type Seg = [number, number, number]; // [시작핀, 끝핀, 길 어두움 0..1]
const BOARD_LABEL = "밝기 실밀도판";
const META_LEFT = "프로필 사진 180핀 · 실 600올 · 주기 2초";
const META_RIGHT = "getImageData · 밝기";
const HINT_TITLE = "사진 실화";
const HINT_DESC = "어두운 곳에 실을 많이 감겠다 매번 고르면 얼굴이 실로 떠오른다";
export default function StringChordDensityPhoto({
duration = "2s",
easing = "ease-out",
color = "#d9c9a3",
n = N,
str = STR,
cycle = CYCLE
}: StringChordDensityPhotoProps) {
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; panel: RGB; dim: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const cxRef = useRef(0) as Box<number>;
const cyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const schedRef = useRef<Seg[] | null>(null);
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 buildProfile(): Float32Array {
const lum = new Float32Array(SS * SS);
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return lum;
o.fillStyle = "#fff"; o.fillRect(0, 0, SS, SS); // 바탕 밝게
o.fillStyle = "#000";
// 하단 좌우 배경 칼럼 — 사진 테두리 어둡기. 아래쪽에도 실이 감기게 하여 판이 비지 않게
o.fillRect(0, SS * 0.80, SS * 0.20, SS * 0.20);
o.fillRect(SS * 0.80, SS * 0.80, SS * 0.20, SS * 0.20);
// 머리 — 중앙 위쪽 큰 원
o.beginPath(); o.arc(SS * 0.50, SS * 0.30, SS * 0.155, 0, Math.PI * 2); o.fill();
// 목
o.fillRect(SS * 0.455, SS * 0.40, SS * 0.09, SS * 0.10);
// 어깨 — 둥근 사다리꼴, 판 아래까지 이어진다
o.beginPath();
o.moveTo(SS * 0.16, SS * 0.94);
o.quadraticCurveTo(SS * 0.22, SS * 0.585, SS * 0.44, SS * 0.50);
o.lineTo(SS * 0.56, SS * 0.50);
o.quadraticCurveTo(SS * 0.78, SS * 0.585, SS * 0.84, SS * 0.94);
o.lineTo(SS * 0.16, SS * 0.94);
o.closePath(); o.fill();
// 머리카락 음영 — 위쪽 반달 더 어둡게(중첩)
o.beginPath(); o.arc(SS * 0.50, SS * 0.325, SS * 0.158, Math.PI, Math.PI * 2); o.fill();
// 뺨·턱 음영 — 살짝 어두운 타원 2
o.fillStyle = "#555";
o.beginPath(); o.ellipse(SS * 0.445, SS * 0.365, SS * 0.026, SS * 0.017, 0.3, 0, Math.PI * 2); o.fill();
o.beginPath(); o.ellipse(SS * 0.555, SS * 0.365, SS * 0.026, SS * 0.017, -0.3, 0, Math.PI * 2); o.fill();
const d = o.getImageData(0, 0, SS, SS).data;
for (let i = 0; i < SS * SS; i++) {
lum[i] = 1 - d[i * 4] / 255; // 어두움 0..1
}
return lum;
}
// 감기 순서 — 매 올을 "남은 후보 중 길이 어두움이 큰 쪽"으로 연결하는 결정적 욕심 스케줄.
// 계산용 고정 좌표(스케줄은 판 크기와 무관하게 0..1 비율 격자 위에서 짠다)
function plan(): Seg[] {
const lum = buildProfile();
const SKIP = Math.round(n * 0.41);
const PINU: XY[] = [];
for (let pi = 0; pi < n; pi++) {
const pa = Math.PI * 2 * pi / n - Math.PI / 2;
PINU.push([0.5 + Math.cos(pa) * 0.5, 0.5 + Math.sin(pa) * 0.5]);
}
function pinXY0(i: number): XY {
const u = PINU[i];
return [u[0] * SS, u[1] * SS];
}
function chordDark0(a: XY, b: XY): number {
let s = 0;
const cnt = 7;
for (let k = 1; k <= cnt; k++) {
const u = k / (cnt + 1);
const x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
const gx = Math.round(x), gy = Math.round(y);
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
s += lum[gy * SS + gx];
}
return s / cnt;
}
const schedule: Seg[] = [];
const used: Record<string, number> = {};
let pos = 0;
for (let s = 0; s < str; s++) {
let bestJ = -1, bestV = -1;
const base = pinXY0(pos);
for (let c = 1; c <= 4; c++) {
const j = (pos + c * SKIP) % n;
const key = pos + "-" + j;
if (used[key]) continue;
const v = chordDark0(base, pinXY0(j));
if (v > bestV) { bestV = v; bestJ = j; }
}
if (bestJ < 0) bestJ = (pos + SKIP) % n;
used[pos + "-" + bestJ] = 1;
schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
pos = bestJ;
}
return schedule;
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [cxRef.current + Math.cos(a) * rRef.current, cyRef.current + Math.sin(a) * rRef.current];
}
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);
rRef.current = Math.min(wRef.current, hRef.current) * 0.47;
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current / 2 + Math.min(hRef.current, wRef.current) * 0.02;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const schedule = schedRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !schedule) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const full = Math.floor(progress * str);
ctx.lineCap = "round";
ctx.shadowBlur = 4;
for (let i = 0; i < full; i++) {
const seg = schedule[i];
const a = pinXY(seg[0]), b = pinXY(seg[1]);
const dens = seg[2]; // 이 올이 지나는 길의 어두움
const recency = (full - i) / str;
// 어두운 길의 올은 진하게, 밝은 길의 올은 성겁게 — 밀도가 명암이 된다
const alpha = 0.24 + 0.62 * dens + 0.2 * Math.pow(recency, 1.4);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.thrd.join(",") + "," + (alpha * 0.3).toFixed(3) + ")";
ctx.lineWidth = 1.1 + dens * 1.1 + recency * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감기가 지난 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
const lim = full > 0 ? schedule[full - 1][1] : 0;
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
if (p <= lim) {
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.6 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.0 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgb(" + col.dim.join(",") + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.8 감기 · 0.8→1 유지(다 감긴 초상을 볼 시간)
const progress = Math.min(1, t / cycle / 0.8);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--se-thread"),
pin: tokRGB(root, "--se-pin"),
panel: tokRGB(root, "--se-panel"),
dim: tokRGB(root, "--se-dim")
};
ctxRef.current = cv.getContext("2d");
schedRef.current = plan(); // 주기마다 재현할 감기 순서를 미리 계산(결정적 — 매번 같은 초상)
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 감긴 실 초상 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, str, cycle]);
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>
);
}05 · 사진 실화 — 밝기를 실 밀도로 바꾸는 초상 — 측정 누적면적 31.687% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
결정적 욕심 스케줄
var SKIP = Math.round(N * 0.41);
var schedule = [];
(function plan() {
var pos = 0, used = {};
for (var s = 0; s < STR; s++) {
var bestJ = -1, bestV = -1;
var base = pinXY0(pos);
for (var c = 1; c <= 4; c++) {
var j = (pos + c * SKIP) % N;
var key = pos + '-' + j;
if (used[key]) continue;
var v = chordDark0(base, pinXY0(j));
if (v > bestV) { bestV = v; bestJ = j; }
}
if (bestJ < 0) bestJ = (pos + SKIP) % N;
used[pos + '-' + bestJ] = 1;
schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
pos = bestJ;
}
})();
06욕심쇠 감기 — 후보 전부 점쳐 한 올만 남기는 최적화
목표 도안 5각 별을 72×72 격자로 두고 감은 판 상태를 같은 크기 보드에 유지한 뒤, 후보 180현을 얹었다 되돌리며 점수 Σ(포화 ink − goal)² 를 재고 가장 낮아지는 한 올만 남기는 탐욕 선택을 190올 반복합니다. 나아질 후보가 없으면 0.37N 건너뛰기 올로 진행을 지킵니다.

<main class="stage">
<div class="sf is-demo" id="sfRoot" role="application" aria-label="욕심쇠 탐색판" tabindex="0">
<canvas class="sf__cv" id="sfCv"></canvas>
<p class="sf__hint">
<span class="sf__title">욕심쇠 감기</span>
<span class="sf__desc">후보 전부를 그려보고 가장 닮은 한 올만 남기는 탐색이 도안을 완성한다</span>
</p>
<div class="sf__bar">
<span class="sf__meta">후보 180현 · 매 단계 1올 · 주기 2초</span>
<span class="sf__meta sf__meta--dim">ImageData · 탐욕 선택</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 욕심쇠 감기(06) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(탐욕 단계 진행)가 만든다.
// 무대 옐로우 — 밝은 판 위 잉크보라 실로 별 도안을 채운다. 캔버스가 판 전체다.
$sf-stage: #ffe9a8;
@include stage($sf-stage);
$sf-panel: #fff7d6; // 감기판 바닥 — 무대보다 한 단계 밝은 크림옐로우
$sf-text: #4a3d20; // 라벨 글자 — 진갈먹 잉크
$sf-thread: #5a5470; // 실 — 잉크보라(밝은 판 위 진한 올)
$sf-cand: #d98fa6; // 탐색 중 후보 올 — 더스티 로즈
$sf-pin: #2e2a3a; // 핀 — 진먹보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $sf-thread;
.sf {
--sf-panel: #{$sf-panel};
--sf-thread: #{$sf-thread};
--sf-cand: #{$sf-cand};
--sf-pin: #{$sf-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sf__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sf__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: $sf-text;
text-shadow: 0 1px 4px rgba($sf-panel, .9);
}
.sf__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sf-text, .88);
text-shadow: 0 1px 4px rgba($sf-panel, .9);
}
.sf__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;
}
.sf__meta {
font-size: 10px; font-weight: 500; color: rgba($sf-text, .78);
background: rgba($sf-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sf:focus-visible { outline: 2px solid $sf-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sf__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 완성된 탐욕 도안 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sf.is-still { 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: #ffe9a8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
--sf-panel: #fff7d6;
--sf-thread: #5a5470;
--sf-cand: #d98fa6;
--sf-pin: #2e2a3a;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sf__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sf__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: #4a3d20;
text-shadow: 0 1px 4px rgba(255, 247, 214, 0.9);
}
.sf__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 61, 32, 0.88);
text-shadow: 0 1px 4px rgba(255, 247, 214, 0.9);
}
.sf__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sf__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 61, 32, 0.78);
background: rgba(255, 247, 214, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.sf:focus-visible {
outline: 2px solid #5a5470;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sf__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-still {
cursor: default !important;
}
}// 스트링아트 욕심쇠 감기(06) — 후보를 전부 그려보고 가장 나은 한 올만 남기는 탐색 루프.
// 목표 도안(오프스크린 캔버스에 절차적으로 그린 별 문양)을 getImageData 로 읽어 격자로 두고,
// 감은 판 상태를 같은 크기 보드(Float32Array)에 유지한다. 매 단계에서 후보 180현을 보드에
// 얹었다 되돌리며 점수를 재고, 목표와 가장 가까워지는 한 올만 실제로 남긴다 —
// 이 탐욕 선택을 반복하면 실만으로 별이 그려진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 180; // 원 둘레 핀 개수
var CAND = 180; // 매 단계 비교하는 후보 현 수
var STEPS = 190; // 주기 안에 완성하는 올 수
var CYCLE = 2.0; // 감기 주기(초)
var SS = 72; // 비교용 축소 격자 크기(SS×SS)
// ────────────────────────────────────────────────────
var root = document.getElementById('sfRoot');
var cvEl = document.getElementById('sfCv');
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('--sf-thread');
var COL_CAND = tokRGB('--sf-cand');
var COL_PIN = tokRGB('--sf-pin');
var COL_PANEL = tokRGB('--sf-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0, CX = 0, CY = 0;
// ── 목표 도안 — 오프스크린에 5각 별을 그리고 ImageData 로 읽어 격자로 둔다 ──
var goal = new Float32Array(SS * SS);
(function buildGoal() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
o.fillStyle = '#000'; o.fillRect(0, 0, SS, SS);
o.strokeStyle = '#fff'; o.lineWidth = 2.6;
o.beginPath();
for (var k = 0; k <= 5; k++) {
var a = -Math.PI / 2 + Math.PI * 4 * k / 5;
var x = SS / 2 + Math.cos(a) * SS * 0.38;
var y = SS / 2 + Math.sin(a) * SS * 0.38;
if (k === 0) o.moveTo(x, y); else o.lineTo(x, y);
}
o.stroke();
var d = o.getImageData(0, 0, SS, SS).data;
for (var i = 0; i < SS * SS; i++) goal[i] = d[i * 4] / 255;
})();
// ── 감기 상태 — 핀 사용 횟수와 확정 올 목록 ──
var pinsUsed = [];
var woven = []; // [i, j] 확정 올
var board = new Float32Array(SS * SS); // 감은 판의 잉크량
function resetBoard() {
for (var b = 0; b < SS * SS; b++) board[b] = 0;
for (var u = 0; u < N; u++) pinsUsed[u] = 0;
woven = [];
}
// 격자 좌표의 핀 위치
function gPin(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [SS / 2 + Math.cos(a) * SS * 0.46, SS / 2 + Math.sin(a) * SS * 0.46];
}
// 올 얹기/되돌리기 — 현을 따라 점을 찍어 잉크량을 더거나 뺀다
function paintChord(i, j, sign) {
var a = gPin(i), b = gPin(j);
var K = 60;
for (var k = 0; k <= K; k++) {
var u = k / K;
var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
var gx = x | 0, gy = y | 0;
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
var idx = gy * SS + gx;
board[idx] += sign * 0.5;
}
}
// 보드가 목표와 얼마나 다른가 — 작을수록 닮았다
function score() {
var s = 0;
for (var i = 0; i < SS * SS; i++) {
var ink = board[i] > 1 ? 1 : board[i]; // 겹친 올은 포화
var diff = ink - goal[i];
s += diff * diff;
}
return s;
}
// 매 단계 — 후보 CAND현을 얹었다 되돌리며 점수를 재고 최선 한 올을 확정한다
function greedyStep() {
var base = score();
var i0 = woven.length ? woven[woven.length - 1][1] : 0; // 이전 올 끝핀에서 이어서
var bestJ = -1, bestSc = base;
for (var c = 1; c <= CAND; c++) {
var j = (i0 + c) % N;
paintChord(i0, j, 1);
var sc = score();
paintChord(i0, j, -1);
if (sc < bestSc) { bestSc = sc; bestJ = j; }
}
if (bestJ >= 0) {
paintChord(i0, bestJ, 1);
woven.push([i0, bestJ]);
pinsUsed[i0]++; pinsUsed[bestJ]++;
return bestJ;
}
// 나아질 후보가 없으면 규칙 올 한 개라도 감아 진행을 지킨다
var fallback = (i0 + Math.round(N * 0.37)) % N;
paintChord(i0, fallback, 1);
woven.push([i0, fallback]);
pinsUsed[i0]++; pinsUsed[fallback]++;
return fallback;
}
// 주기마다 재현할 STEPS 단계의 탐욕 결과를 미리 계산해 둔다(같은 도안이 반복된다)
var endpoints = [];
(function precompute() {
resetBoard();
for (var s = 0; s < STEPS; s++) endpoints.push(greedyStep());
})();
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [CX + Math.cos(a) * R, CY + Math.sin(a) * R];
}
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);
R = Math.min(W, H) * 0.48;
CX = W / 2; CY = H / 2;
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var full = Math.min(STEPS, Math.floor(progress * STEPS));
var frac = progress * STEPS - full; // 지금 탐색 중인 올의 진행 0..1
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.3)';
for (var i = 0; i < full; i++) {
var seg = woven[i];
var a = pinXY(seg[0]), b = pinXY(seg[1]);
var recency = (full - i) / STEPS;
var alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.2 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 지금 탐색 중인 올 — 확정되어 자라나는 후보(더스티 로즈)
if (full > 0 && full <= STEPS && frac > 0) {
var i0 = woven[full - 1][1];
var jNow = endpoints[full - 1];
var a2 = pinXY(i0), b2 = pinXY(jNow);
var grow = Math.min(1, frac * 2);
ctx.strokeStyle = 'rgba(' + COL_CAND.join(',') + ',' + (0.6 + 0.25 * Math.sin(t * 9)).toFixed(3) + ')';
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(a2[0], a2[1]);
ctx.lineTo(a2[0] + (b2[0] - a2[0]) * grow, a2[1] + (b2[1] - a2[1]) * grow);
ctx.stroke();
}
// 핀 — 감긴 핀일수록 크고 진하며 순차적으로 숨쉰다(유지 구간에도 맥동)
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
var use = pinsUsed[p];
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
if (use > 0) {
var sz = 1 + Math.min(1.4, use * 0.28) + 0.9 * pulse;
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.6 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], sz, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',0.4)';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
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) % CYCLE;
// 0→0.85 탐색+감기 · 0.85→1 유지(완성 도안을 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.85);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 완성된 탐욕 도안 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringThreadGreedyOrder — vanilla 판(run/326/demo/06-string-thread-greedy-order/index.html) 의 React 포트.
// 후보를 전부 그려보고 가장 나은 한 올만 남기는 탐색 루프. 목표 도안(오프스크린 캔버스에
// 절차적으로 그린 별 문양)을 getImageData 로 읽어 격자로 두고, 감은 판 상태를 같은 크기
// 보드(Float32Array)에 유지한다. 매 단계에서 후보 180현을 보드에 얹었다 되돌리며 점수를
// 재고, 목표와 가장 가까워지는 한 올만 실제로 남긴다 — 이 탐욕 선택을 반복하면 실만으로 별이 그려진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringThreadGreedyOrder.module.scss";
export type StringThreadGreedyOrderProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 원 둘레 핀 개수
cand?: number; // 매 단계 비교하는 후보 현 수
steps?: number; // 주기 안에 완성하는 올 수
cycle?: number; // 감기 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 180; // 원 둘레 핀 개수
const CAND = 180; // 매 단계 비교하는 후보 현 수
const STEPS = 190; // 주기 안에 완성하는 올 수
const CYCLE = 2.0; // 감기 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 72; // 비교용 축소 격자 크기(SS×SS)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
type Woven = [number, number]; // 확정 올 [시작핀, 끝핀]
const BOARD_LABEL = "욕심쇠 탐색판";
const META_LEFT = "후보 180현 · 매 단계 1올 · 주기 2초";
const META_RIGHT = "ImageData · 탐욕 선택";
const HINT_TITLE = "욕심쇠 감기";
const HINT_DESC = "후보 전부를 그려보고 가장 닮은 한 올만 남기는 탐색이 도안을 완성한다";
export default function StringThreadGreedyOrder({
duration = "2s",
easing = "ease-out",
color = "#5a5470",
n = N,
cand = CAND,
steps = STEPS,
cycle = CYCLE
}: StringThreadGreedyOrderProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; candCol: RGB; pin: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const cxRef = useRef(0) as Box<number>;
const cyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const stateRef = useRef<{ woven: Woven[]; endpoints: number[]; pinsUsed: number[] } | null>(null);
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)];
}
// ── 목표 도안 — 오프스크린에 5각 별을 그리고 ImageData 로 읽어 격자로 둔다 ──
function buildGoal(): Float32Array {
const goal = new Float32Array(SS * SS);
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return goal;
o.fillStyle = "#000"; o.fillRect(0, 0, SS, SS);
o.strokeStyle = "#fff"; o.lineWidth = 2.6;
o.beginPath();
for (let k = 0; k <= 5; k++) {
const a = -Math.PI / 2 + Math.PI * 4 * k / 5;
const x = SS / 2 + Math.cos(a) * SS * 0.38;
const y = SS / 2 + Math.sin(a) * SS * 0.38;
if (k === 0) o.moveTo(x, y); else o.lineTo(x, y);
}
o.stroke();
const d = o.getImageData(0, 0, SS, SS).data;
for (let i = 0; i < SS * SS; i++) goal[i] = d[i * 4] / 255;
return goal;
}
// 주기마다 재현할 steps 단계의 탐욕 결과를 미리 계산해 둔다(같은 도안이 반복된다)
function precompute(): { woven: Woven[]; endpoints: number[]; pinsUsed: number[] } {
const goal = buildGoal();
const board = new Float32Array(SS * SS); // 감은 판의 잉크량
const pinsUsed: number[] = [];
const woven: Woven[] = [];
const endpoints: number[] = [];
// 격자 좌표의 핀 위치
function gPin(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [SS / 2 + Math.cos(a) * SS * 0.46, SS / 2 + Math.sin(a) * SS * 0.46];
}
// 올 얹기/되돌리기 — 현을 따라 점을 찍어 잉크량을 더거나 뺀다
function paintChord(i: number, j: number, sign: number): void {
const a = gPin(i), b = gPin(j);
const K = 60;
for (let k = 0; k <= K; k++) {
const u = k / K;
const x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
const gx = x | 0, gy = y | 0;
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
board[gy * SS + gx] += sign * 0.5;
}
}
// 보드가 목표와 얼마나 다른가 — 작을수록 닮았다
function score(): number {
let s = 0;
for (let i = 0; i < SS * SS; i++) {
const ink = board[i] > 1 ? 1 : board[i]; // 겹친 올은 포화
const diff = ink - goal[i];
s += diff * diff;
}
return s;
}
// 매 단계 — 후보 cand현을 얹었다 되돌리며 점수를 재고 최선 한 올을 확정한다
function greedyStep(): number {
const base = score();
const i0 = woven.length ? woven[woven.length - 1][1] : 0; // 이전 올 끝핀에서 이어서
let bestJ = -1, bestSc = base;
for (let c = 1; c <= cand; c++) {
const j = (i0 + c) % n;
paintChord(i0, j, 1);
const sc = score();
paintChord(i0, j, -1);
if (sc < bestSc) { bestSc = sc; bestJ = j; }
}
if (bestJ >= 0) {
paintChord(i0, bestJ, 1);
woven.push([i0, bestJ]);
pinsUsed[i0]++; pinsUsed[bestJ]++;
return bestJ;
}
// 나아질 후보가 없으면 규칙 올 한 개라도 감아 진행을 지킨다
const fallback = (i0 + Math.round(n * 0.37)) % n;
paintChord(i0, fallback, 1);
woven.push([i0, fallback]);
pinsUsed[i0]++; pinsUsed[fallback]++;
return fallback;
}
for (let u = 0; u < n; u++) pinsUsed[u] = 0;
for (let s = 0; s < steps; s++) endpoints.push(greedyStep());
return { woven, endpoints, pinsUsed };
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [cxRef.current + Math.cos(a) * rRef.current, cyRef.current + Math.sin(a) * rRef.current];
}
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);
rRef.current = Math.min(wRef.current, hRef.current) * 0.48;
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current / 2;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const st = stateRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !st) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const full = Math.min(steps, Math.floor(progress * steps));
const frac = progress * steps - full; // 지금 탐색 중인 올의 진행 0..1
ctx.lineCap = "round";
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.3)";
for (let i = 0; i < full; i++) {
const seg = st.woven[i];
const a = pinXY(seg[0]), b = pinXY(seg[1]);
const recency = (full - i) / steps;
const alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.2 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 지금 탐색 중인 올 — 확정되어 자라나는 후보(더스티 로즈)
if (full > 0 && full <= steps && frac > 0) {
const i0 = st.woven[full - 1][1];
const jNow = st.endpoints[full - 1];
const a2 = pinXY(i0), b2 = pinXY(jNow);
const grow = Math.min(1, frac * 2);
ctx.strokeStyle = "rgba(" + col.candCol.join(",") + "," + (0.6 + 0.25 * Math.sin(t * 9)).toFixed(3) + ")";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(a2[0], a2[1]);
ctx.lineTo(a2[0] + (b2[0] - a2[0]) * grow, a2[1] + (b2[1] - a2[1]) * grow);
ctx.stroke();
}
// 핀 — 감긴 핀일수록 크고 진하며 순차적으로 숨쉰다(유지 구간에도 맥동)
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
const use = st.pinsUsed[p];
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
if (use > 0) {
const sz = 1 + Math.min(1.4, use * 0.28) + 0.9 * pulse;
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.6 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], sz, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgba(" + col.pin.join(",") + ",0.4)";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.85 탐색+감기 · 0.85→1 유지(완성 도안을 볼 시간)
const progress = Math.min(1, t / cycle / 0.85);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sf-thread"),
candCol: tokRGB(root, "--sf-cand"),
pin: tokRGB(root, "--sf-pin"),
panel: tokRGB(root, "--sf-panel")
};
ctxRef.current = cv.getContext("2d");
stateRef.current = precompute(); // 감기 상태·탐욕 결과를 미리 계산(결정적)
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 완성된 탐욕 도안 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, cand, steps, cycle]);
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>
);
}06 · 욕심쇠 감기 — 다음 실을 고르는 최적화 — 측정 누적면적 33.037% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
보드 점수와 탐욕 한 걸음
function paintChord(i, j, sign) {
var a = gPin(i), b = gPin(j);
var K = 60;
for (var k = 0; k <= K; k++) {
var u = k / K;
var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
var gx = x | 0, gy = y | 0;
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
var idx = gy * SS + gx;
board[idx] += sign * 0.5;
}
}
function score() {
var s = 0;
for (var i = 0; i < SS * SS; i++) {
var ink = board[i] > 1 ? 1 : board[i]; // 겹친 올은 포화
var diff = ink - goal[i];
s += diff * diff;
}
return s;
}
07윤곽 핀 배치 — 그림 가장자리에서 핀을 뽑는다
두 톤 평면에 그린 잎 문양을 바깥에서 홍수 채우기로 흰 배경을 지워 외곽 경계만 남기고 각도순 정렬로 등간격 90핀을 박는, 도안을 이미지에서 자동으로 만드는 파이프라인입니다. 잎맥은 어둠에 둘러싸여 바깥과 이어지지 않아 핀에서 빠지고 판이 가로로 길어 가로 좌표만 1.7배로 늘려 펼치는데, 380올 중 첫 90올은 이웃 핀만 이어 테두리를 그리고 나머지는 7·11·16칸 순환으로 속을 채웁니다.

<main class="stage">
<div class="sg is-demo" id="sgRoot" role="application" aria-label="윤곽 감기판" tabindex="0">
<canvas class="sg__cv" id="sgCv"></canvas>
<p class="sg__hint">
<span class="sg__title">윤곽 핀 배치</span>
<span class="sg__desc">그림 가장자리의 밝기 급변 픽셀에 핀을 박으면 모양 자체에 실이 감긴다</span>
</p>
<div class="sg__bar">
<span class="sg__meta">경계 픽셀 90핀 · 실 380올 · 주기 2초</span>
<span class="sg__meta sg__meta--dim">getImageData · 경계 검출</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 윤곽 핀 배치(07) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(경계 핀 감기)가 만든다.
// 무대 오렌지 — 따뜻한 판 위 크림 실로 잎 윤곽을 감는다. 캔버스가 판 전체다.
$sg-stage: #c49d92;
@include stage($sg-stage);
$sg-panel: #d0aa9f; // 감기판 바닥 — 무대보다 한 단계 밝은 연산호
$sg-text: #4a1d0d; // 라벨 글자 — 진적갈 잉크
$sg-thread: #fdf3e0; // 실 — 크림(연산호 판 위 밝은 올)
$sg-pin: #3a180b; // 핀 — 진갈먹
$duration: $dur-loop;
$easing: $ease-out;
$color: $sg-thread;
.sg {
--sg-panel: #{$sg-panel};
--sg-thread: #{$sg-thread};
--sg-pin: #{$sg-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sg__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sg__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: $sg-text;
text-shadow: 0 1px 4px rgba($sg-panel, .9);
}
.sg__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sg-text, .78);
text-shadow: 0 1px 4px rgba($sg-panel, .9);
}
.sg__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;
}
.sg__meta {
font-size: 10px; font-weight: 500; color: rgba($sg-text, .78);
background: rgba($sg-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sg:focus-visible { outline: 2px solid $sg-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sg__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 잎 문양 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sg.is-still { 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: #c49d92;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sg {
--sg-panel: #d0aa9f;
--sg-thread: #fdf3e0;
--sg-pin: #3a180b;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sg__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sg__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: #4a1d0d;
text-shadow: 0 1px 4px rgba(208, 170, 159, 0.9);
}
.sg__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 29, 13, 0.78);
text-shadow: 0 1px 4px rgba(208, 170, 159, 0.9);
}
.sg__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sg__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 29, 13, 0.78);
background: rgba(208, 170, 159, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.sg:focus-visible {
outline: 2px solid #fdf3e0;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sg__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-still {
cursor: default !important;
}
}// 스트링아트 윤곽 핀 배치(07) — 이미지 경계(엣지) 검출로 핀 위치를 자동으로 정하는 계산.
// 오프스크린 캔버스에 절차적으로 잎(leaf) 문양을 그리고 getImageData 로 밝기를 읽는다.
// 바깥에서 흰 배경을 홍수 채우기해 닿는 지점만 골라 바깥 테두리에 90개 핀을 박는다 —
// 잎맥 같은 속 무늬는 빼고 외곽선만 핀이 된다. 첫 바퀴는 이웃 핀을 이어 테두리를 그리고
// 둘째 바퀴부터 몇 칸 건너뛴 올로 속을 채운다 — 모양 자체가 핀판이 되는 국면.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPIN = 90; // 경계에서 뽑는 핀 개수
var STR = 380; // 감는 실 올 수
var CYCLE = 2.0; // 감기 완료 주기(초)
var SS = 110; // 경계 검출 격자 크기(SS×SS)
// ────────────────────────────────────────────────────
var root = document.getElementById('sgRoot');
var cvEl = document.getElementById('sgCv');
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('--sg-thread');
var COL_PIN = tokRGB('--sg-pin');
var COL_PANEL = tokRGB('--sg-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
// ── 절차적 잎 문양 → 밝기 → 경계 픽셀 → 핀 90개(0..1 비율 좌표) ──
var PINS = [];
(function buildPins() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
// 배경 그라데이션 대신 두 톤 평면 — 경계가 또렷해야 핀이 정확히 나온다
o.fillStyle = '#fff'; o.fillRect(0, 0, SS, SS);
o.fillStyle = '#000';
// 잎 몸통 — 두 개의 2차 곡선으로 이은 통통한 잎사귀(폭을 넓게 — 실 감기 면적)
o.beginPath();
o.moveTo(SS * 0.50, SS * 0.06);
o.quadraticCurveTo(SS * 1.02, SS * 0.46, SS * 0.50, SS * 0.94);
o.quadraticCurveTo(SS * -0.02, SS * 0.46, SS * 0.50, SS * 0.06);
o.closePath(); o.fill();
// 잎맥 — 가운데 세로줄과 옆줄 3쌍을 하얗게 파낸다(경계 추가)
o.strokeStyle = '#fff'; o.lineWidth = 2.2; o.lineCap = 'round';
o.beginPath();
o.moveTo(SS * 0.50, SS * 0.14); o.lineTo(SS * 0.50, SS * 0.86);
o.stroke();
for (var v = 0; v < 3; v++) {
var yy = SS * (0.30 + v * 0.17);
var ww = SS * 0.30 * (1 - v * 0.22);
o.lineWidth = 1.6;
o.beginPath(); o.moveTo(SS * 0.50, yy); o.lineTo(SS * 0.50 - ww, yy - SS * 0.07); o.stroke();
o.beginPath(); o.moveTo(SS * 0.50, yy); o.lineTo(SS * 0.50 + ww, yy - SS * 0.07); o.stroke();
}
// 밝기 격자
var lum = new Float32Array(SS * SS);
var d = o.getImageData(0, 0, SS, SS).data;
for (var i = 0; i < SS * SS; i++) lum[i] = d[i * 4];
// 바깥 경계만 — 바깥에서 흰 배경을 홍수 채우기(BFS)해 어두운 몸통에 닿는 지점.
// 잎맥(속의 하얀 줄)은 어두운 몸에 둘러싸여 바깥과 이어지지 않으니 핀에서 제외된다.
var dark = new Uint8Array(SS * SS);
for (var b0 = 0; b0 < SS * SS; b0++) dark[b0] = lum[b0] < 128 ? 1 : 0;
var seen = new Uint8Array(SS * SS);
var bnd = new Uint8Array(SS * SS);
var stack = [0];
seen[0] = 1;
var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
while (stack.length) {
var q = stack.pop();
var qx = q % SS, qy = (q - qx) / SS;
for (var d4 = 0; d4 < 4; d4++) {
var nx = qx + NX[d4], ny = qy + NY[d4];
if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
var qi = ny * SS + nx;
if (dark[qi]) bnd[q] = 1; // 흰 픽셀 옆이 몸통 → 외곽선 바로 바깥 점
else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
}
}
var ring = [];
for (var r0 = 0; r0 < SS * SS; r0++) if (bnd[r0]) ring.push([r0 % SS, (r0 - r0 % SS) / SS]);
// 각도 순 정렬 후 고리를 따라 일정 간격으로 NPIN개 — 감기가 모양을 따라 돌게
var cxg = SS / 2, cyg = SS / 2;
ring.sort(function (a, b) {
return Math.atan2(a[1] - cyg, a[0] - cxg) - Math.atan2(b[1] - cyg, b[0] - cxg);
});
var stepR = ring.length / NPIN;
for (var p = 0; p < NPIN; p++) {
var q2 = ring[Math.floor(p * stepR) % ring.length];
PINS.push([(q2[0] / SS - 0.5) * 1.12 + 0.5, (q2[1] / SS - 0.5) * 1.12 + 0.5]);
}
})();
var CX = 0, CY = 0;
function pinPx(i) {
var u = PINS[i % PINS.length];
var s = Math.min(W, H);
// 가로로 넓게 — 캔버스 폭(480)이 높이(300)보다 넓으니 잎을 넓적하게 펴 꽉 채운다
return [CX + (u[0] - 0.5) * s * 1.7, CY + (u[1] - 0.5) * s * 1.02];
}
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);
CX = W / 2; CY = H / 2;
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var n = PINS.length;
var full = Math.floor(progress * STR);
ctx.lineCap = 'round';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.4)';
for (var i = 0; i < full; i++) {
// 감기 규칙 — 첫 바퀴(n올)는 이웃 핀만 이어 잎 테두리를 그리고,
// 둘째 바퀴부터 7·11·16칸 건너뛴 올을 순환해 속을 채운다(포락선이 잎을 메운다)
var K = i < n ? 1 : [7, 11, 16][(i - n) % 3];
var a = pinPx(i % n), b = pinPx((i + K) % n);
var recency = (full - i) / STR;
var alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.lineWidth = 3.1 + recency * 2.2;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 올이 닿은 핀은 밝게 커진다. 스캔 하이라이트(경계 검출 순서 흔적)
for (var p = 0; p < n; p++) {
var xy = pinPx(p);
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
var rr = 1.6 + 1.3 * pulse;
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.55 + 0.4 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], rr, 0, Math.PI * 2); ctx.fill();
}
}
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) % CYCLE;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 핀 맥동이 계속 움직인다
var progress = Math.min(1, t / CYCLE / 0.8);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 감긴 잎 문양 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringPinsFromImageEdge — vanilla 판(run/326/demo/07-string-pins-from-image-edge/index.html) 의 React 포트.
// 이미지 경계(엣지) 검출로 핀 위치를 자동으로 정하는 계산. 오프스크린 캔버스에 절차적으로
// 잎(leaf) 문양을 그리고 getImageData 로 밝기를 읽는다. 바깥에서 흰 배경을 홍수 채우기해
// 닿는 지점만 골라 바깥 테두리에 90개 핀을 박는다 — 잎맥 같은 속 무늬는 빼고 외곽선만
// 핀이 된다. 첫 바퀴는 이웃 핀을 이어 테두리를 그리고 둘째 바퀴부터 몇 칸 건너뛴 올로
// 속을 채운다 — 모양 자체가 핀판이 되는 국면.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringPinsFromImageEdge.module.scss";
export type StringPinsFromImageEdgeProps = {
duration?: string;
easing?: string;
color?: string;
npin?: number; // 경계에서 뽑는 핀 개수
str?: number; // 감는 실 올 수
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPIN = 90; // 경계에서 뽑는 핀 개수
const STR = 380; // 감는 실 올 수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 110; // 경계 검출 격자 크기(SS×SS)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
const BOARD_LABEL = "윤곽 감기판";
const META_LEFT = "경계 픽셀 90핀 · 실 380올 · 주기 2초";
const META_RIGHT = "getImageData · 경계 검출";
const HINT_TITLE = "윤곽 핀 배치";
const HINT_DESC = "그림 가장자리의 밝기 급변 픽셀에 핀을 박으면 모양 자체에 실이 감긴다";
export default function StringPinsFromImageEdge({
duration = "2s",
easing = "ease-out",
color = "#fdf3e0",
npin = NPIN,
str = STR,
cycle = CYCLE
}: StringPinsFromImageEdgeProps) {
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; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const cxRef = useRef(0) as Box<number>;
const cyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const pinsRef = useRef<XY[] | null>(null);
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)];
}
// ── 절차적 잎 문양 → 밝기 → 경계 픽셀 → 핀 90개(0..1 비율 좌표) ──
function buildPins(): XY[] {
const pins: XY[] = [];
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return pins;
// 배경 그라데이션 대신 두 톤 평면 — 경계가 또렷해야 핀이 정확히 나온다
o.fillStyle = "#fff"; o.fillRect(0, 0, SS, SS);
o.fillStyle = "#000";
// 잎 몸통 — 두 개의 2차 곡선으로 이은 통통한 잎사귀(폭을 넓게 — 실 감기 면적)
o.beginPath();
o.moveTo(SS * 0.50, SS * 0.06);
o.quadraticCurveTo(SS * 1.02, SS * 0.46, SS * 0.50, SS * 0.94);
o.quadraticCurveTo(SS * -0.02, SS * 0.46, SS * 0.50, SS * 0.06);
o.closePath(); o.fill();
// 잎맥 — 가운데 세로줄과 옆줄 3쌍을 하얗게 파낸다(경계 추가)
o.strokeStyle = "#fff"; o.lineWidth = 2.2; o.lineCap = "round";
o.beginPath();
o.moveTo(SS * 0.50, SS * 0.14); o.lineTo(SS * 0.50, SS * 0.86);
o.stroke();
for (let v = 0; v < 3; v++) {
const yy = SS * (0.30 + v * 0.17);
const ww = SS * 0.30 * (1 - v * 0.22);
o.lineWidth = 1.6;
o.beginPath(); o.moveTo(SS * 0.50, yy); o.lineTo(SS * 0.50 - ww, yy - SS * 0.07); o.stroke();
o.beginPath(); o.moveTo(SS * 0.50, yy); o.lineTo(SS * 0.50 + ww, yy - SS * 0.07); o.stroke();
}
// 밝기 격자
const lum = new Float32Array(SS * SS);
const d = o.getImageData(0, 0, SS, SS).data;
for (let i = 0; i < SS * SS; i++) lum[i] = d[i * 4];
// 바깥 경계만 — 바깥에서 흰 배경을 홍수 채우기(BFS)해 어두운 몸통에 닿는 지점.
// 잎맥(속의 하얀 줄)은 어두운 몸에 둘러싸여 바깥과 이어지지 않으니 핀에서 제외된다.
const dark = new Uint8Array(SS * SS);
for (let b0 = 0; b0 < SS * SS; b0++) dark[b0] = lum[b0] < 128 ? 1 : 0;
const seen = new Uint8Array(SS * SS);
const bnd = new Uint8Array(SS * SS);
const stack: number[] = [0];
seen[0] = 1;
const NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
while (stack.length) {
const q = stack.pop() as number;
const qx = q % SS, qy = (q - qx) / SS;
for (let d4 = 0; d4 < 4; d4++) {
const nx = qx + NX[d4], ny = qy + NY[d4];
if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
const qi = ny * SS + nx;
if (dark[qi]) bnd[q] = 1; // 흰 픽셀 옆이 몸통 → 외곽선 바로 바깥 점
else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
}
}
const ring: XY[] = [];
for (let r0 = 0; r0 < SS * SS; r0++) if (bnd[r0]) ring.push([r0 % SS, (r0 - r0 % SS) / SS]);
// 각도 순 정렬 후 고리를 따라 일정 간격으로 npin개 — 감기가 모양을 따라 돌게
const cxg = SS / 2, cyg = SS / 2;
ring.sort(function (a: XY, b: XY): number {
return Math.atan2(a[1] - cyg, a[0] - cxg) - Math.atan2(b[1] - cyg, b[0] - cxg);
});
const stepR = ring.length / npin;
for (let p = 0; p < npin; p++) {
const q2 = ring[Math.floor(p * stepR) % ring.length];
pins.push([(q2[0] / SS - 0.5) * 1.12 + 0.5, (q2[1] / SS - 0.5) * 1.12 + 0.5]);
}
return pins;
}
function pinPx(i: number): XY {
const pins = pinsRef.current;
if (!pins) return [0, 0];
const u = pins[i % pins.length];
const s = Math.min(wRef.current, hRef.current);
// 가로로 넓게 — 캔버스 폭이 높이보다 넓으니 잎을 넓적하게 펴 꽉 채운다
return [cxRef.current + (u[0] - 0.5) * s * 1.7, cyRef.current + (u[1] - 0.5) * s * 1.02];
}
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);
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current / 2;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const pins = pinsRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !pins) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const n = pins.length;
const full = Math.floor(progress * str);
ctx.lineCap = "round";
ctx.shadowBlur = 10;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.4)";
for (let i = 0; i < full; i++) {
// 감기 규칙 — 첫 바퀴(n올)는 이웃 핀만 이어 잎 테두리를 그리고,
// 둘째 바퀴부터 7·11·16칸 건너뛴 올을 순환해 속을 채운다(포락선이 잎을 메운다)
const K = i < n ? 1 : [7, 11, 16][(i - n) % 3];
const a = pinPx(i % n), b = pinPx((i + K) % n);
const recency = (full - i) / str;
const alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.lineWidth = 3.1 + recency * 2.2;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감긴 올이 닿은 핀은 밝게 커진다. 스캔 하이라이트(경계 검출 순서 흔적)
for (let p = 0; p < n; p++) {
const xy = pinPx(p);
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
const rr = 1.6 + 1.3 * pulse;
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.55 + 0.4 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], rr, 0, Math.PI * 2); ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 핀 맥동이 계속 움직인다
const progress = Math.min(1, t / cycle / 0.8);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sg-thread"),
pin: tokRGB(root, "--sg-pin"),
panel: tokRGB(root, "--sg-panel")
};
ctxRef.current = cv.getContext("2d");
pinsRef.current = buildPins(); // 홍수 채우기 윤곽 핀을 미리 계산(결정적)
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 감긴 잎 문양 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [npin, str, cycle]);
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>
);
}07 · 윤곽 핀 배치 — 그림의 가장자리에서 핀을 뽑는다 — 측정 누적면적 32.166% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
홍수 채우기로 외곽 경계만 남기기
// 바깥 경계만 — 바깥에서 흰 배경을 홍수 채우기(BFS)해 어두운 몸통에 닿는 지점.
// 잎맥(속의 하얀 줄)은 어두운 몸에 둘러싸여 바깥과 이어지지 않으니 핀에서 제외된다.
var dark = new Uint8Array(SS * SS);
for (var b0 = 0; b0 < SS * SS; b0++) dark[b0] = lum[b0] < 128 ? 1 : 0;
var seen = new Uint8Array(SS * SS);
var bnd = new Uint8Array(SS * SS);
var stack = [0];
seen[0] = 1;
var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
while (stack.length) {
var q = stack.pop();
var qx = q % SS, qy = (q - qx) / SS;
for (var d4 = 0; d4 < 4; d4++) {
var nx = qx + NX[d4], ny = qy + NY[d4];
if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
var qi = ny * SS + nx;
if (dark[qi]) bnd[q] = 1; // 흰 픽셀 옆이 몸통 → 외곽선 바로 바깥 점
else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
}
}
var K = i < n ? 1 : [7, 11, 16][(i - n) % 3];
08직조 무늬 — 두 방향 실이 위상차로 짜는 격자
세로 경사 26올과 가로 위사 18올을 각각 14분할 폴리라인으로 놓고 법선 방향 사인 출렁임 ±3.5px 을 준 뒤 위상차 0.5 만큼 늦게 흘려 보내는 베틀의 축소판입니다. 반투명 globalAlpha 0.5 로 두 방향을 겹치면 교차점이 밝아지며 모아레가 절제된 문양 타일로 정착되는데, 두 방향이 판 전체를 덮는 유일한 구성이라 누적면적 50.97% 로 가장 넓습니다.

<main class="stage">
<div class="sh is-demo" id="shRoot" role="application" aria-label="직조 실험판" tabindex="0">
<canvas class="sh__cv" id="shCv"></canvas>
<p class="sh__hint">
<span class="sh__title">직조 무늬</span>
<span class="sh__desc">두 방향 실을 위상 어긋나게 교차시키면 모아레가 문양 타일로 정착된다</span>
</p>
<div class="sh__bar">
<span class="sh__meta">실 2방향 · 위상차 0.5 · 주기 2초</span>
<span class="sh__meta sh__meta--dim">globalAlpha · sin</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 직조 무늬(08) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(경위사 감기 + 출렁임)가 만든다.
// 무대 종이 — 밝은 판 위 두 파스텔 실로 직조 타일을 짠다. 캔버스가 판 전체다.
$sh-stage: #f1ecdf;
@include stage($sh-stage);
$sh-panel: #f9f6ee; // 직조판 바닥 — 종이보다 한 단계 밝은 크림
$sh-text: #3a3448; // 라벨 글자 — 진먹보라 잉크
$sh-thread: #8fafc9; // 경사 실 — 파스텔 블루그레이
$sh-cross: #d9a8a0; // 위사 실 — 파스텔 코랄 로즈
$sh-pin: #35304a; // 직조 핀 — 진먹보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $sh-thread;
.sh {
--sh-panel: #{$sh-panel};
--sh-thread: #{$sh-thread};
--sh-cross: #{$sh-cross};
--sh-pin: #{$sh-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sh__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sh__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: $sh-text;
text-shadow: 0 1px 4px rgba($sh-panel, .9);
}
.sh__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sh-text, .78);
text-shadow: 0 1px 4px rgba($sh-panel, .9);
}
.sh__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;
}
.sh__meta {
font-size: 10px; font-weight: 500; color: rgba($sh-text, .78);
background: rgba($sh-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sh:focus-visible { outline: 2px solid $sh-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sh__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 짜진 직조 타일 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sh.is-still { 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);
}
.sh {
--sh-panel: #f9f6ee;
--sh-thread: #8fafc9;
--sh-cross: #d9a8a0;
--sh-pin: #35304a;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sh__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sh__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);
}
.sh__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);
}
.sh__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sh__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;
}
.sh:focus-visible {
outline: 2px solid #8fafc9;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sh__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-still {
cursor: default !important;
}
}// 스트링아트 직조 무늬(08) — 두 실 방향이 위상차를 두고 교차하며 짜는 직조 계산.
// 세로 경사(warp)와 가로 위사(weft)를 각각 실 격자로 놓고, 위상차 PHASE 만큼
// 어긋나게 흘려 보낸다. globalAlpha 로 두 방향을 반투명하게 겹치면 교차점이
// 밝아지며 모아레 문양 타일이 정착된다 — 베틀의 경위사 교차를 축소한 판.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NW = 26; // 경사(세로 실) 올 수
var NF = 18; // 위사(가로 실) 올 수
var PHASE = 0.5; // 두 방향 위상차(주기 비율) — 0.25 로 줄이면 문양이 촘촘해진다(변형 3)
var BASE = 0.5; // 실 기본 globalAlpha — 0.25 로 내리면 더 성긴 레이스가 된다(변형 3)
var CYCLE = 2.0; // 직조 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('shRoot');
var cvEl = document.getElementById('shCv');
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('--sh-thread');
var COL_CROSS = tokRGB('--sh-cross');
var COL_PIN = tokRGB('--sh-pin');
var COL_PANEL = tokRGB('--sh-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var pad = 0;
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);
pad = Math.min(W, H) * 0.06;
}
// 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
function thread(x0, y0, x1, y1, wob, t) {
var K = 14;
ctx.beginPath();
ctx.moveTo(x0, y0);
for (var k = 1; k <= K; k++) {
var u = k / K;
var x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
// 진행 방향의 법선으로 wobble — 실의 장력 출렁임
var dx = x1 - x0, dy = y1 - y0;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len, ny = dx / len;
var off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
ctx.lineTo(x + nx * off, y + ny * off);
}
ctx.stroke();
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var iw = W - 2 * pad, ih = H - 2 * pad;
var doneW = Math.floor(progress * NW);
var i, u, recency, alpha;
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.3)';
// 경사(세로) — 위상 0. 왼→오른 순서로 감는다
for (i = 0; i < doneW; i++) {
u = i / (NW - 1);
recency = Math.max(0, 1 - (progress - u));
alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
ctx.globalAlpha = Math.min(0.95, alpha);
ctx.strokeStyle = 'rgb(' + COL_THRD.join(',') + ')';
ctx.lineWidth = 2.6 + recency * 1.8;
thread(pad + iw * u, pad, pad + iw * u, H - pad, 3.5, t);
}
// 위사(가로) — 위상차 PHASE 만큼 늦게 흐른다
var pF = Math.max(0, progress - PHASE) / (1 - PHASE);
var doneF2 = Math.floor(pF * NF);
for (i = 0; i < doneF2; i++) {
u = i / (NF - 1);
recency = Math.max(0, 1 - (pF - u));
alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
ctx.globalAlpha = Math.min(0.95, alpha);
ctx.strokeStyle = 'rgb(' + COL_CROSS.join(',') + ')';
ctx.lineWidth = 2.6 + recency * 1.8;
thread(pad, pad + ih * u, W - pad, pad + ih * u, 3.5, t + PHASE * 6);
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// 핀 — 네 변에 박힌 직조 핀. 감긴 핀은 밝게
var k;
for (k = 0; k < NW; k++) {
u = k / (NW - 1);
var done = k < doneW;
ctx.fillStyle = done ? 'rgb(' + COL_PIN.join(',') + ')' : 'rgba(' + COL_PIN.join(',') + ',0.35)';
var r0 = done ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad + iw * u, pad, r0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pad + iw * u, H - pad, r0, 0, Math.PI * 2); ctx.fill();
}
for (k = 0; k < NF; k++) {
u = k / (NF - 1);
var done2 = k < doneF2;
ctx.fillStyle = done2 ? 'rgb(' + COL_PIN.join(',') + ')' : 'rgba(' + COL_PIN.join(',') + ',0.35)';
var r2 = done2 ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(W - pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
}
}
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) % CYCLE;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 실 출렁임(wobble)이 계속 움직인다
var progress = Math.min(1, t / CYCLE / 0.8);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 짜진 직조 타일 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringLoomWeavePattern — vanilla 판(run/326/demo/08-string-loom-weave-pattern/index.html) 의 React 포트.
// 두 실 방향이 위상차를 두고 교차하며 짜는 직조 계산. 세로 경사(warp)와 가로 위사(weft)를
// 각각 실 격자로 놓고, 위상차 PHASE 만큼 어긋나게 흘려 보낸다. globalAlpha 로 두 방향을
// 반투명하게 겹치면 교차점이 밝아지며 모아레 문양 타일이 정착된다 — 베틀의 경위사 교차를 축소한 판.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringLoomWeavePattern.module.scss";
export type StringLoomWeavePatternProps = {
duration?: string;
easing?: string;
color?: string;
nw?: number; // 경사(세로 실) 올 수
nf?: number; // 위사(가로 실) 올 수
phase?: number; // 두 방향 위상차(주기 비율) — 0.25 로 줄이면 문양이 촘촘해진다(변형 3)
base?: number; // 실 기본 globalAlpha — 0.25 로 내리면 더 성긴 레이스가 된다(변형 3)
cycle?: number; // 직조 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NW = 26; // 경사(세로 실) 올 수
const NF = 18; // 위사(가로 실) 올 수
const PHASE = 0.5; // 두 방향 위상차(주기 비율)
const BASE = 0.5; // 실 기본 globalAlpha
const CYCLE = 2.0; // 직조 주기(초) — 설정 초(2s)와 같아 한 직조가 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const BOARD_LABEL = "직조 실험판";
const META_LEFT = "실 2방향 · 위상차 0.5 · 주기 2초";
const META_RIGHT = "globalAlpha · sin";
const HINT_TITLE = "직조 무늬";
const HINT_DESC = "두 방향 실을 위상 어긋나게 교차시키면 모아레가 문양 타일로 정착된다";
export default function StringLoomWeavePattern({
duration = "2s",
easing = "ease-out",
color = "#8fafc9",
nw = NW,
nf = NF,
phase = PHASE,
base = BASE,
cycle = CYCLE
}: StringLoomWeavePatternProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; cross: RGB; pin: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const padRef = useRef(0) as Box<number>;
const rafRef = useRef(0) 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 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);
padRef.current = Math.min(wRef.current, hRef.current) * 0.06;
}
// 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
function thread(x0: number, y0: number, x1: number, y1: number, wob: number, t: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const K = 14;
ctx.beginPath();
ctx.moveTo(x0, y0);
for (let k = 1; k <= K; k++) {
const u = k / K;
const x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
// 진행 방향의 법선으로 wobble — 실의 장력 출렁임
const dx = x1 - x0, dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / len, ny = dx / len;
const off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
ctx.lineTo(x + nx * off, y + ny * off);
}
ctx.stroke();
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current, pad = padRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const iw = W - 2 * pad, ih = H - 2 * pad;
const doneW = Math.floor(progress * nw);
let i: number, u: number, recency: number, alpha: number;
ctx.lineCap = "round";
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.3)";
// 경사(세로) — 위상 0. 왼→오른 순서로 감는다
for (i = 0; i < doneW; i++) {
u = i / (nw - 1);
recency = Math.max(0, 1 - (progress - u));
alpha = base * (0.72 + 0.5 * Math.pow(recency, 1.2));
ctx.globalAlpha = Math.min(0.95, alpha);
ctx.strokeStyle = "rgb(" + col.thrd.join(",") + ")";
ctx.lineWidth = 2.6 + recency * 1.8;
thread(pad + iw * u, pad, pad + iw * u, H - pad, 3.5, t);
}
// 위사(가로) — 위상차 phase 만큼 늦게 흐른다
const pF = Math.max(0, progress - phase) / (1 - phase);
const doneF2 = Math.floor(pF * nf);
for (i = 0; i < doneF2; i++) {
u = i / (nf - 1);
recency = Math.max(0, 1 - (pF - u));
alpha = base * (0.72 + 0.5 * Math.pow(recency, 1.2));
ctx.globalAlpha = Math.min(0.95, alpha);
ctx.strokeStyle = "rgb(" + col.cross.join(",") + ")";
ctx.lineWidth = 2.6 + recency * 1.8;
thread(pad, pad + ih * u, W - pad, pad + ih * u, 3.5, t + phase * 6);
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// 핀 — 네 변에 박힌 직조 핀. 감긴 핀은 밝게
let k: number;
for (k = 0; k < nw; k++) {
u = k / (nw - 1);
const done = k < doneW;
ctx.fillStyle = done ? "rgb(" + col.pin.join(",") + ")" : "rgba(" + col.pin.join(",") + ",0.35)";
const r0 = done ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad + iw * u, pad, r0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pad + iw * u, H - pad, r0, 0, Math.PI * 2); ctx.fill();
}
for (k = 0; k < nf; k++) {
u = k / (nf - 1);
const done2 = k < doneF2;
ctx.fillStyle = done2 ? "rgb(" + col.pin.join(",") + ")" : "rgba(" + col.pin.join(",") + ",0.35)";
const r2 = done2 ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(W - pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 실 출렁임(wobble)이 계속 움직인다
const progress = Math.min(1, t / cycle / 0.8);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sh-thread"),
cross: tokRGB(root, "--sh-cross"),
pin: tokRGB(root, "--sh-pin"),
panel: tokRGB(root, "--sh-panel")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 짜진 직조 타일 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nw, nf, phase, base, cycle]);
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>
);
}08 · 직조 무늬 — 실이 스치며 만드는 격자 문양 — 측정 누적면적 50.901% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
법선 출렁임과 위상차
// 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
function thread(x0, y0, x1, y1, wob, t) {
var K = 14;
ctx.beginPath();
ctx.moveTo(x0, y0);
for (var k = 1; k <= K; k++) {
var u = k / K;
var x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
// 진행 방향의 법선으로 wobble — 실의 장력 출렁임
var dx = x1 - x0, dy = y1 - y0;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len, ny = dx / len;
var off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
ctx.lineTo(x + nx * off, y + ny * off);
}
ctx.stroke();
}
// 위사(가로) — 위상차 PHASE 만큼 늦게 흐른다
var pF = Math.max(0, progress - PHASE) / (1 - PHASE);
var doneF2 = Math.floor(pF * NF);
09대문 감기 장면 — 글자 핀으로 워드마크를 짜는 히어로
오프스크린에 찍은 두 글자(GS)를 바깥 홍수 채우기로 윤곽 링만 뽑아 연결 성분으로 나누고 링을 따라 도는 순서로 64핀을 정해 160올을 감는 대문 장면입니다. 첫 바퀴는 이웃 핀으로 윤곽을 그리되 글자가 바뀌는 다리 올만 알파 0.18 로 엷게 건너가고 둘째 바퀴부터는 같은 글자 안에서만 5·9·13칸 건너뛰어 속을 성기게 채우며, 뒤편 반경 max(W,H)·0.42 의 스포트라이트가 감기가 차오를수록 밝아져 완성 문장을 비춥니다.

<main class="stage">
<div class="si is-demo" id="siRoot" role="application" aria-label="대문 감기 장면 실험판" tabindex="0">
<canvas class="si__cv" id="siCv"></canvas>
<p class="si__hint">
<span class="si__title">대문 감기 장면</span>
<span class="si__desc">글자 핀에서 실이 순서대로 감겨 브랜드 문장이 완성된다</span>
</p>
<div class="si__bar">
<span class="si__meta">글자 핀 64개 · 실 160올 · 주기 2초</span>
<span class="si__meta si__meta--dim">createRadialGradient · sin</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 대문 감기 장면(09) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(글자 감기 + 스포트라이트)가 만든다.
// 무대 잉크 — 어두운 판 위 골드 실로 워드마크를 띄운다. 캔버스가 판 전체다.
$si-stage: #1a1720;
@include stage($si-stage);
$si-panel: #241f2d; // 감기판 바닥 — 무대보다 한 단계 밝은 먹보라
$si-text: #d5cfe6; // 라벨 글자 — 밝은 라일락 회색
$si-thread: #e2d8c3; // 실 — 골드 파스텔(어두운 판 위 밝은 올)
$si-glow: #ece0cb; // 스포트라이트 — 밝은 골드
$si-pin: #f0e6d2; // 글자 핀 — 크림
$duration: $dur-loop;
$easing: $ease-out;
$color: $si-thread;
.si {
--si-panel: #{$si-panel};
--si-thread: #{$si-thread};
--si-glow: #{$si-glow};
--si-pin: #{$si-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.si__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.si__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: $si-text;
text-shadow: 0 1px 6px rgba($si-panel, 1);
}
.si__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($si-text, .78);
text-shadow: 0 1px 6px rgba($si-panel, 1);
}
.si__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;
}
.si__meta {
font-size: 10px; font-weight: 500; color: rgba($si-text, .78);
background: rgba($si-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.si:focus-visible { outline: 2px solid $si-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.si__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 워드마크 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.si.is-still { 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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.si {
--si-panel: #241f2d;
--si-thread: #e2d8c3;
--si-glow: #ece0cb;
--si-pin: #f0e6d2;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.si__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.si__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: #d5cfe6;
text-shadow: 0 1px 6px #241f2d;
}
.si__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.si__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.si__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.si:focus-visible {
outline: 2px solid #e2d8c3;
outline-offset: -2px;
}
@media (max-width: 340px) {
.si__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.si.is-still {
cursor: default !important;
}
}// 스트링아트 대문 감기 장면(09) — 글자 윤곽을 핀으로 삼아 워드마크를 실로 짜는 히어로.
// 오프스크린 캔버스에 두 글자("GS" — godrichstory 머리글자)를 크게 그려 getImageData 로
// 읽고, 바깥 홍수 채우기로 **바깥 윤곽 링만** 뽑아 연결 성분(G 링·S 링)으로 나눈 뒤
// 각 링을 따라 도는 순서로 64개 핀을 정한다. 첫 바퀴는 바로 옆 핀을 이어 윤곽을 그리고
// 둘째 바퀴부터 건너뛴 올로 속을 성기게 채운다 — 실이 차오르면 글자가 떠오르고,
// 뒤편 중앙의 createRadialGradient 스포트라이트가 완성 문장을 비춘다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPIN = 64; // 글자 핀 개수
var STR = 160; // 감는 실 올 수
var CYCLE = 2.0; // 감기 완료 주기(초)
var SS = 120; // 글자 샘플 격자 크기
// ────────────────────────────────────────────────────
var root = document.getElementById('siRoot');
var cvEl = document.getElementById('siCv');
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('--si-thread');
var COL_GLOW = tokRGB('--si-glow');
var COL_PIN = tokRGB('--si-pin');
var COL_PANEL = tokRGB('--si-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
// ── 글자 → 핀 64개(글자별 구간 기록, 0..1 비율) ──
var PINS = [], PINC = [], RUNS = [];
(function buildPins() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
o.fillStyle = '#000'; o.fillRect(0, 0, SS, SS);
o.fillStyle = '#fff';
o.font = '900 ' + Math.floor(SS * 0.52) + 'px sans-serif';
o.textAlign = 'center'; o.textBaseline = 'middle';
o.fillText('G', SS * 0.27, SS * 0.52);
o.fillText('S', SS * 0.76, SS * 0.52);
var d = o.getImageData(0, 0, SS, SS).data;
// 진 글자 픽셀 중 **바깥 윤곽만** — 바깥에서 홍수 채우기(BFS)해 닿는 지점.
// 속(글자 내부) 픽셀은 바깥과 이어지지 않으니 핀에서 제외되고, 얇은 윤곽 링만 남는다
var ink = new Uint8Array(SS * SS);
for (var i0 = 0; i0 < SS * SS; i0++) ink[i0] = d[i0 * 4] > 128 ? 1 : 0;
var seen = new Uint8Array(SS * SS);
var isIn = new Uint8Array(SS * SS);
var stack = [0]; seen[0] = 1;
var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
while (stack.length) {
var q = stack.pop();
var qx = q % SS, qy = (q - qx) / SS;
for (var d4 = 0; d4 < 4; d4++) {
var nx = qx + NX[d4], ny = qy + NY[d4];
if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
var qi = ny * SS + nx;
if (ink[qi]) isIn[q] = 1; // 바깥 픽셀 옆이 글자 → 윤곽 바로 바깥 점
else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
}
}
// 윤곽 링을 연결 성분으로 나눈다(G 링과 S 링이 따로)
var ring = [];
for (var r0 = 0; r0 < SS * SS; r0++) if (isIn[r0]) ring.push(r0);
var taken = new Uint8Array(SS * SS);
var comps = [];
for (var r1 = 0; r1 < ring.length; r1++) {
if (taken[ring[r1]]) continue;
var comp = [ring[r1]], st2 = [ring[r1]];
taken[ring[r1]] = 1;
while (st2.length) {
var q2 = st2.pop();
var qx2 = q2 % SS, qy2 = (q2 - qx2) / SS;
for (var dx = -1; dx <= 1; dx++) {
for (var dy = -1; dy <= 1; dy++) {
var nx2 = qx2 + dx, ny2 = qy2 + dy;
if (nx2 < 0 || ny2 < 0 || nx2 >= SS || ny2 >= SS) continue;
var qi2 = ny2 * SS + nx2;
if (isIn[qi2] && !taken[qi2]) { taken[qi2] = 1; comp.push(qi2); st2.push(qi2); }
}
}
}
comps.push(comp);
}
// 성분을 왼쪽부터 정렬 — G 링을 다 감은 뒤 S 링으로 감기가 넘어간다
comps.sort(function (a, b) {
var ax = SS + 1, bx = SS + 1;
for (var i2 = 0; i2 < a.length; i2++) { var v = a[i2] % SS; if (v < ax) ax = v; }
for (var i3 = 0; i3 < b.length; i3++) { var w = b[i3] % SS; if (w < bx) bx = w; }
return ax - bx;
});
// 각 링을 최근접 이웃 경로로 얽는다 — 링이 얇아(몇 픽셀) 경로가 윤곽을 따라 돈다.
// pathComp 로 각 지점이 몇 번째 글자(성분)인지 함께 남긴다
var path = [], pathComp = [];
for (var c0 = 0; c0 < comps.length; c0++) {
var pts = [];
for (var c1 = 0; c1 < comps[c0].length; c1++) {
var qq = comps[c0][c1];
pts.push([qq % SS / SS, ((qq - qq % SS) / SS) / SS]);
}
var used = [], cur2 = 0;
for (var s2 = 0; s2 < pts.length; s2++) {
path.push(pts[cur2]); pathComp.push(c0); used[cur2] = 1;
var bi2 = -1, bd2 = 1e9;
for (var j2 = 0; j2 < pts.length; j2++) {
if (used[j2]) continue;
var ddx = pts[j2][0] - pts[cur2][0], ddy = pts[j2][1] - pts[cur2][1];
var dd = ddx * ddx + ddy * ddy;
if (dd < bd2) { bd2 = dd; bi2 = j2; }
}
if (bi2 < 0) break;
cur2 = bi2;
}
}
// 경로를 따라 균등 간격으로 NPIN개 — 윤곽 위 등 간격 핀.
// PINC 는 핀의 글자 번호, RUNS 는 글자별 핀 구간 [시작, 끝) — 채움 올이 글자 안에서만 놀게
for (var k = 0; k < NPIN; k++) {
var pi = Math.floor(k * path.length / NPIN) % path.length;
PINS.push(path[pi]);
PINC.push(pathComp[pi]);
if (!RUNS.length || RUNS[RUNS.length - 1][0] !== pathComp[pi]) RUNS.push([pathComp[pi], k, k + 1]);
else RUNS[RUNS.length - 1][2] = k + 1;
}
})();
var CX = 0, CY = 0;
function pinPx(i) {
var u = PINS[i % PINS.length];
var s = Math.min(W, H);
// 글자 종횡비 유지 — 늘어나지 않게, 가로만 여유 있게
return [CX + (u[0] - 0.5) * s * 1.5, CY + (u[1] - 0.5) * s * 1.0];
}
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);
CX = W / 2; CY = H * 0.53;
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
// 뒤편 스포트라이트 — createRadialGradient 로 완성 문장을 비춘다
var lit = 0.5 + 0.5 * progress;
var sx = W / 2 + Math.sin(t * 0.9) * W * 0.06;
var sy = H * 0.53 + Math.cos(t * 0.7) * H * 0.05;
var rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.42);
rg.addColorStop(0, 'rgba(' + COL_GLOW.join(',') + ',' + (0.5 * lit).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(' + COL_GLOW.join(',') + ',0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
var full = Math.floor(progress * STR);
var n = PINS.length;
ctx.lineCap = 'round';
ctx.shadowBlur = 7;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.45)';
for (var i = 0; i < full; i++) {
if (i < n) {
// 첫 바퀴 — 바로 옆 핀을 이어 글자 윤곽을 진하게 그린다(G 링 → S 링).
// 글자 사이(성분이 바뀌는 한 올)만 엷고 가늘게 건너간다
var bridge = PINC[i] !== PINC[(i + 1) % n];
var a1 = pinPx(i), b1 = pinPx((i + 1) % n);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + (bridge ? 0.18 : 0.92) + ')';
ctx.lineWidth = bridge ? 1.0 : 2.7;
if (bridge) ctx.shadowBlur = 0;
ctx.beginPath(); ctx.moveTo(a1[0], a1[1]); ctx.lineTo(b1[0], b1[1]); ctx.stroke();
if (bridge) ctx.shadowBlur = 7;
} else {
// 둘째 바퀴부터 — **같은 글자 안에서만** 5·9·13칸 건너뛴 올로 속을 성기게 채운다.
// 글자를 넘는 긴 올이 없으니 실 다발이 글자 모양대로만 묶인다
var f = i - n;
var run = RUNS[f % RUNS.length];
var m = run[2] - run[1];
var c = Math.floor(f / RUNS.length);
var K2 = [5, 9, 13][c % 3] % Math.max(2, m - 1);
var q0 = run[1] + (c % m);
var q1 = run[1] + ((c % m) + K2) % m;
var a2 = pinPx(q0), b2 = pinPx(q1);
var recency = (full - i) / STR;
var alpha = 0.22 + 0.3 * Math.pow(recency, 1.1);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
ctx.lineWidth = 1.3;
ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();
}
}
ctx.shadowBlur = 0;
// 글자 핀 — 감기가 지나간 핀은 밝게 숨쉰다
for (var p = 0; p < n; p++) {
var xy = pinPx(p);
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.5 + 0.45 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.5 + 1.2 * pulse, 0, Math.PI * 2); ctx.fill();
}
}
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) % CYCLE;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 스포트라이트·핀 맥동이 계속 움직인다
var progress = Math.min(1, t / CYCLE / 0.8);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 다 감긴 워드마크 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringThreadDecorHero — vanilla 판(run/326/demo/09-string-thread-decor-hero/index.html) 의 React 포트.
// 글자 윤곽을 핀으로 삼아 워드마크를 실로 짜는 히어로. 오프스크린 캔버스에 두 글자("GS" —
// godrichstory 머리글자)를 크게 그려 getImageData 로 읽고, 바깥 홍수 채우기로 바깥 윤곽 링만
// 뽑아 연결 성분(G 링·S 링)으로 나눈 뒤 각 링을 따라 도는 순서로 64개 핀을 정한다.
// 첫 바퀴는 바로 옆 핀을 이어 윤곽을 그리고 둘째 바퀴부터 건너뛴 올로 속을 성기게 채운다 —
// 실이 차오르면 글자가 떠오르고, 뒤편 중앙의 createRadialGradient 스포트라이트가 완성 문장을 비춘다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringThreadDecorHero.module.scss";
export type StringThreadDecorHeroProps = {
duration?: string;
easing?: string;
color?: string;
npin?: number; // 글자 핀 개수
str?: number; // 감는 실 올 수
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPIN = 64; // 글자 핀 개수
const STR = 160; // 감는 실 올 수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 120; // 글자 샘플 격자 크기
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
type Run = [number, number, number]; // 글자별 핀 구간 [성분, 시작, 끝)
const BOARD_LABEL = "대문 감기 장면 실험판";
const META_LEFT = "글자 핀 64개 · 실 160올 · 주기 2초";
const META_RIGHT = "createRadialGradient · sin";
const HINT_TITLE = "대문 감기 장면";
const HINT_DESC = "글자 핀에서 실이 순서대로 감겨 브랜드 문장이 완성된다";
export default function StringThreadDecorHero({
duration = "2s",
easing = "ease-out",
color = "#e2d8c3",
npin = NPIN,
str = STR,
cycle = CYCLE
}: StringThreadDecorHeroProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; glow: RGB; pin: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const cxRef = useRef(0) as Box<number>;
const cyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const pinsRef = useRef<{ pins: XY[]; pinc: number[]; runs: Run[] } | null>(null);
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)];
}
// ── 글자 → 핀 64개(글자별 구간 기록, 0..1 비율) ──
function buildPins(): { pins: XY[]; pinc: number[]; runs: Run[] } {
const PINS: XY[] = [], PINC: number[] = [], RUNS: Run[] = [];
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return { pins: PINS, pinc: PINC, runs: RUNS };
o.fillStyle = "#000"; o.fillRect(0, 0, SS, SS);
o.fillStyle = "#fff";
o.font = "900 " + Math.floor(SS * 0.52) + "px sans-serif";
o.textAlign = "center"; o.textBaseline = "middle";
o.fillText("G", SS * 0.27, SS * 0.52);
o.fillText("S", SS * 0.76, SS * 0.52);
const d = o.getImageData(0, 0, SS, SS).data;
// 진 글자 픽셀 중 바깥 윤곽만 — 바깥에서 홍수 채우기(BFS)해 닿는 지점.
// 속(글자 내부) 픽셀은 바깥과 이어지지 않으니 핀에서 제외되고, 얇은 윤곽 링만 남는다
const ink = new Uint8Array(SS * SS);
for (let i0 = 0; i0 < SS * SS; i0++) ink[i0] = d[i0 * 4] > 128 ? 1 : 0;
const seen = new Uint8Array(SS * SS);
const isIn = new Uint8Array(SS * SS);
const stack: number[] = [0]; seen[0] = 1;
const NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
while (stack.length) {
const q = stack.pop() as number;
const qx = q % SS, qy = (q - qx) / SS;
for (let d4 = 0; d4 < 4; d4++) {
const nx = qx + NX[d4], ny = qy + NY[d4];
if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
const qi = ny * SS + nx;
if (ink[qi]) isIn[q] = 1; // 바깥 픽셀 옆이 글자 → 윤곽 바로 바깥 점
else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
}
}
// 윤곽 링을 연결 성분으로 나눈다(G 링과 S 링이 따로)
const ring: number[] = [];
for (let r0 = 0; r0 < SS * SS; r0++) if (isIn[r0]) ring.push(r0);
const taken = new Uint8Array(SS * SS);
const comps: number[][] = [];
for (let r1 = 0; r1 < ring.length; r1++) {
if (taken[ring[r1]]) continue;
const comp: number[] = [ring[r1]], st2: number[] = [ring[r1]];
taken[ring[r1]] = 1;
while (st2.length) {
const q2 = st2.pop() as number;
const qx2 = q2 % SS, qy2 = (q2 - qx2) / SS;
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const nx2 = qx2 + dx, ny2 = qy2 + dy;
if (nx2 < 0 || ny2 < 0 || nx2 >= SS || ny2 >= SS) continue;
const qi2 = ny2 * SS + nx2;
if (isIn[qi2] && !taken[qi2]) { taken[qi2] = 1; comp.push(qi2); st2.push(qi2); }
}
}
}
comps.push(comp);
}
// 성분을 왼쪽부터 정렬 — G 링을 다 감은 뒤 S 링으로 감기가 넘어간다
comps.sort(function (a: number[], b: number[]): number {
let ax = SS + 1, bx = SS + 1;
for (let i2 = 0; i2 < a.length; i2++) { const v = a[i2] % SS; if (v < ax) ax = v; }
for (let i3 = 0; i3 < b.length; i3++) { const w = b[i3] % SS; if (w < bx) bx = w; }
return ax - bx;
});
// 각 링을 최근접 이웃 경로로 얽는다 — 링이 얇아(몇 픽셀) 경로가 윤곽을 따라 돈다.
// pathComp 로 각 지점이 몇 번째 글자(성분)인지 함께 남긴다
const path: XY[] = [], pathComp: number[] = [];
for (let c0 = 0; c0 < comps.length; c0++) {
const pts: XY[] = [];
for (let c1 = 0; c1 < comps[c0].length; c1++) {
const qq = comps[c0][c1];
pts.push([qq % SS / SS, ((qq - qq % SS) / SS) / SS]);
}
const used: number[] = [];
let cur2 = 0;
for (let s2 = 0; s2 < pts.length; s2++) {
path.push(pts[cur2]); pathComp.push(c0); used[cur2] = 1;
let bi2 = -1, bd2 = 1e9;
for (let j2 = 0; j2 < pts.length; j2++) {
if (used[j2]) continue;
const ddx = pts[j2][0] - pts[cur2][0], ddy = pts[j2][1] - pts[cur2][1];
const dd = ddx * ddx + ddy * ddy;
if (dd < bd2) { bd2 = dd; bi2 = j2; }
}
if (bi2 < 0) break;
cur2 = bi2;
}
}
// 경로를 따라 균등 간격으로 npin개 — 윤곽 위 등 간격 핀.
// pinc 는 핀의 글자 번호, runs 는 글자별 핀 구간 [시작, 끝) — 채움 올이 글자 안에서만 놀게
for (let k = 0; k < npin; k++) {
const pi = Math.floor(k * path.length / npin) % path.length;
PINS.push(path[pi]);
PINC.push(pathComp[pi]);
if (!RUNS.length || RUNS[RUNS.length - 1][0] !== pathComp[pi]) RUNS.push([pathComp[pi], k, k + 1]);
else RUNS[RUNS.length - 1][2] = k + 1;
}
return { pins: PINS, pinc: PINC, runs: RUNS };
}
function pinPx(i: number): XY {
const st = pinsRef.current;
if (!st) return [0, 0];
const u = st.pins[i % st.pins.length];
const s = Math.min(wRef.current, hRef.current);
// 글자 종횡비 유지 — 늘어나지 않게, 가로만 여유 있게
return [cxRef.current + (u[0] - 0.5) * s * 1.5, cyRef.current + (u[1] - 0.5) * s * 1.0];
}
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);
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current * 0.53;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const st = pinsRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !st) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
// 뒤편 스포트라이트 — createRadialGradient 로 완성 문장을 비춘다
const lit = 0.5 + 0.5 * progress;
const sx = W / 2 + Math.sin(t * 0.9) * W * 0.06;
const sy = H * 0.53 + Math.cos(t * 0.7) * H * 0.05;
const rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.42);
rg.addColorStop(0, "rgba(" + col.glow.join(",") + "," + (0.5 * lit).toFixed(3) + ")");
rg.addColorStop(1, "rgba(" + col.glow.join(",") + ",0)");
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
const full = Math.floor(progress * str);
const n = st.pins.length;
ctx.lineCap = "round";
ctx.shadowBlur = 7;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.45)";
for (let i = 0; i < full; i++) {
if (i < n) {
// 첫 바퀴 — 바로 옆 핀을 이어 글자 윤곽을 진하게 그린다(G 링 → S 링).
// 글자 사이(성분이 바뀌는 한 올)만 엷고 가늘게 건너간다
const bridge = st.pinc[i] !== st.pinc[(i + 1) % n];
const a1 = pinPx(i), b1 = pinPx((i + 1) % n);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + (bridge ? 0.18 : 0.92) + ")";
ctx.lineWidth = bridge ? 1.0 : 2.7;
if (bridge) ctx.shadowBlur = 0;
ctx.beginPath(); ctx.moveTo(a1[0], a1[1]); ctx.lineTo(b1[0], b1[1]); ctx.stroke();
if (bridge) ctx.shadowBlur = 7;
} else {
// 둘째 바퀴부터 — 같은 글자 안에서만 5·9·13칸 건너뛴 올로 속을 성기게 채운다.
// 글자를 넘는 긴 올이 없으니 실 다발이 글자 모양대로만 묶인다
const f = i - n;
const run = st.runs[f % st.runs.length];
const m = run[2] - run[1];
const c = Math.floor(f / st.runs.length);
const K2 = [5, 9, 13][c % 3] % Math.max(2, m - 1);
const q0 = run[1] + (c % m);
const q1 = run[1] + ((c % m) + K2) % m;
const a2 = pinPx(q0), b2 = pinPx(q1);
const recency = (full - i) / str;
const alpha = 0.22 + 0.3 * Math.pow(recency, 1.1);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + alpha.toFixed(3) + ")";
ctx.lineWidth = 1.3;
ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();
}
}
ctx.shadowBlur = 0;
// 글자 핀 — 감기가 지나간 핀은 밝게 숨쉰다
for (let p = 0; p < n; p++) {
const xy = pinPx(p);
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.5 + 0.45 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.5 + 1.2 * pulse, 0, Math.PI * 2); ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.8 감기 · 0.8→1 유지 — 유지 구간에도 스포트라이트·핀 맥동이 계속 움직인다
const progress = Math.min(1, t / cycle / 0.8);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--si-thread"),
glow: tokRGB(root, "--si-glow"),
pin: tokRGB(root, "--si-pin"),
panel: tokRGB(root, "--si-panel")
};
ctxRef.current = cv.getContext("2d");
pinsRef.current = buildPins(); // 글자 윤곽 핀을 미리 계산(결정적)
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 다 감긴 워드마크 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [npin, str, cycle]);
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>
);
}09 · 대문 감기 장면 — 브랜드 글자를 실로 짜는 히어로 — 측정 누적면적 43.626% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
글자 핀 좌표와 스포트라이트
function pinPx(i) {
var u = PINS[i % PINS.length];
var s = Math.min(W, H);
// 글자 종횡비 유지 — 늘어나지 않게, 가로만 여유 있게
return [CX + (u[0] - 0.5) * s * 1.5, CY + (u[1] - 0.5) * s * 1.0];
}
// 뒤편 스포트라이트 — createRadialGradient 로 완성 문장을 비춘다
var lit = 0.5 + 0.5 * progress;
var sx = W / 2 + Math.sin(t * 0.9) * W * 0.06;
var sy = H * 0.53 + Math.cos(t * 0.7) * H * 0.05;
var rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.42);
rg.addColorStop(0, 'rgba(' + COL_GLOW.join(',') + ',' + (0.5 * lit).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(' + COL_GLOW.join(',') + ',0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
건너뛰기 칸수가 서로소가 아니면 그림이 여기서 깨지는 함정
아홉 판을 실제로 만들며 걸린 지점은 매번 같은 두 갈래였습니다 — 핀 사이 건너뛰기 칸수를 잘못 고르는 경우와 그림에서 핀 좌표를 뽑아내는 규칙을 어기는 경우입니다. 둘 다 코드는 멀쩡히 돌아가고 에러 없이 그려지는데 결과물만 원래 도안과 달라진다는 공통점이 있어 처음 붙여 볼 때 가장 자주 걸립니다.
서로소가 아닌 건너뛰기 칸수
01 의 N=120 과 SKIP=49 는 서로소라 한 바퀴에 120올이 전부 다른 핀을 지난다. 공약수가 있는 칸수를 고르면 실이 같은 핀 몇 개만 돌아 포락선에 구멍이 뚫리는데 이것이 처음에 가장 자주 만나는 깨짐입니다.
그림에서 핀을 뽑을 때 속 무늬가 넘는 순간
05·06 의 스케줄을 매 프레임 다시 짜면 그림이 떨리면서 계산 비용이 폭주하는데 한 번 짠 배열로 매번 같은 순서를 재생해야 합니다. 06 은 보드 값이 1 을 넘으면 1 로 자르는 포화가 있어야 실 밀도가 도안 밝기와 대응합니다. 07·09 에서 어두운 픽셀 전부를 핀으로 쓰면 잎맥과 글자 속 줄까지 핀이 되어 외곽선이 지워지고, 07 의 배경을 그라디언트로 그리면 밝기 임계값(128) 이 흐려져 핀이 어긋납니다. 09 의 글자 사이 다리 올을 진하게 감으면 실 다발이 글자 밖으로 흐르니 다리는 알파 0.18·굵기 1.0 으로 엷게 두는 것이 정해입니다. 이 아홉 판은 핀 배치부터 감기 루프까지 통째로 index.html 하나씩으로 들어 있는데 그 원본을 그대로 묶은 암호 zip 의 비밀번호는 77qjdznz 이고 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.
세 변형 값 중 하나가 곧바로 접근성 대응이 되는 이유
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 건너뛰기 칸수 | 01 건너뛰기 2칸 → 5칸 | 2칸씩 감던 실이 5칸씩 건너뛰면 곡선의 꽃잎 수가 늘고 무늬가 촘촘해진다 |
| 핀 개수 | 03 핀 60개 → 180개 | 핀이 세 배로 늘면 곡선이 매끈해지되 실이 겹쳐 번지는 지점이 생긴다 |
| 실 명도 | 08 globalAlpha 0.5 → 0.25 | 실을 반투명하게 낼수록 겹친 곳이 어두워져 문양의 대비가 뒤집힌다 |
동작을 줄인 사용자에게 주는 장면
모든 판은 시작할 때 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 애니메이션 루프를 돌리지 않고 감기가 진행된 장면 한 장을 그려 고정한 뒤 .is-still 클래스를 붙입니다. 여덟 판은 다 감긴 장면(04 의 완성 부채꼴 등)을 고정하고 03 만 감기 중간의 k=2 카디오이드를 고정하니 정지 사용자에게도 빈 캔버스가 아니라 도안이 남습니다.
손으로 만지는 판과 화면 낭독
04 가 pointermove 로 컨트롤 핀을 끌 수 있고 나머지 판은 자동 재생입니다. 모든 실험판에는 사람이 알 수 있는 aria-label 과 tabindex 가 붙고 핀 수·실 올 수·주기 초 같은 측정 메타가 화면 아래 한 줄로 항상 표시됩니다.
이런 화면에서 쓴다 — 브랜드 히어로와 사진 초상 카드
09 는 글자 모양 핀 사이를 실로 채워 로고나 서비스명을 감는 장면이라 랜딩 첫 화면의 히어로 워드마크로 그대로 옮겨 붙일 수 있습니다. 로딩이 끝나기 전까지 감기가 진행 중인 상태를 보여 주다가 완성되는 순간 다음 화면으로 넘기면 기다리는 시간이 빈 스피너보다 덜 지루합니다.
05 는 인물 사진의 밝기를 실 밀도로 옮기는 판이라 프로필 카드나 소개 페이지의 얼굴 그림을 사진 대신 이 초상으로 바꿔 넣는 용도에 맞습니다. 07 은 사진 가장자리만 핀으로 뽑아 그리므로 제품 컷의 윤곽선만 남기는 카드 배경으로 쓰면 사진 없이도 그 물건임을 알아볼 수 있습니다.
셋 다 핀 좌표만 서로 다른 이미지로 바꿔 넣으면 되고 감기 루프와 캔버스 크기는 그대로 두면 됩니다.
원핀 실 그림을 처음 붙일 때 자주 나오는 질문 FAQ
스트링아트 만들기를 이 데모로 처음 시작해도 따라할 수 있나요?
핵심은 핀 좌표를 sin·cos 로 놓고 i번 핀에서 i+SKIP 번 핀으로 현을 걷는 루프 하나인데 01 판의 스무 줄 남짓이 그 전부입니다. zip 안에는 아홉 판을 같은 조건으로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음은 상수 블록의 칸수·핀 수를 하나씩 바꿔 보는 순서가 가장 빠릅니다.
스트링아트 하트처럼 내가 그린 도안을 감을 수 있나요?
06 이 정확히 그 구조입니다. 목표 도안을 오프스크린 캔버스에 흰 스트로크로 그리는 buildGoal 안의 별 문양을 하트나 로고로 바꾸면 격자가 그 도안을 읽어 탐욕 선택이 같은 방식으로 감아 줍니다. 05 도 실루엣을 그리는 부분만 바꾸면 다른 초상이 됩니다.
감기가 끝나면 화면은 멈추나요?
주기의 유지 구간에도 화면은 살아 있습니다. 핀은 순차적으로 맥동하고(0.5 + 0.5·sin) 방금 감은 올이 가장 밝은 채로 남아 08 의 실 출렁임과 09 의 스포트라이트도 계속 움직입니다. 완성본 한 장만 정적으로 그려 두면 감는 손이 사라져 스트링아트가 아니라는 것이 이 판들의 설계 전제입니다.
다시 보면 — 왜 직선 실만으로 곡선이 보이는가
스트링아트의 곡선은 그린 것이 아니라 감긴 것입니다. 실 한 올은 핀 a 에서 핀 b 로의 직선 현이고 곡선 호출은 단 한 번도 없는데, 현들이 겹치는 자리의 밝기가 연속으로 이어지면서 눈은 그 밝기의 가장자리를 하나의 곡선으로 읽습니다. 02 의 역순 짝짓기가 이 원리를 가장 드러냅니다 — 왼변 i번과 아래변 (M−1−i)번을 이은 32개 현은 저마다 다른 기울기의 직선이지만 모두 한 원호에 닿아, 원호를 그린 적 없는 판에 원호가 떠오릅니다. 01 의 장미꼴과 03 의 카디오이드도 같은 일입니다 — 감기 규칙이 현의 방향 분포를 정하면 곡선은 그 분포가 만드는 밝기 누적의 포락선으로 저절로 나타납니다.
곡선을 직접 그리는 순간 원리가 사라진다
여기에 arc 나 2차 곡선 호출을 한 줄 섞는 순간 화면은 여전히 예뻐 보이지만 스트링아트가 아닙니다. 실 감기의 설득력은 모든 선이 양끝 핀에 걸린 직선이라는 약속에서 나오는데 곡선 한 올이 그 약속을 깨면 남은 직선들의 규칙성도 함께 죽습니다. 그래서 아홉 판의 실을 전부 곡선 없이 moveTo·lineTo 만으로 그립니다 — 핀 점을 찍는 작은 원만 arc 로 그렸습니다.









